The Interpreter

Deceptive Fails: One Diff Check Away

Two requests, same endpoint, same 200 response code, same 325-byte body. One was real, and one was the anti-bot lying to me. The only difference was in the response headers.

Before we get into the technical aspect, I first must give you some context. A few weeks ago, I'd been tackling an issue on a completely unrelated website, and it was blocking me, the actual paying customer. Given my background, I decided to look closer. I was trying to login to my account and the response was saying "Invalid Credentials", when I knew with certainty that they were correct. Looking at the response, nothing stood out to me, so I decided to take note of the issue and put the entire response into a notepad. I swapped browsers, kept my devtools open so I could capture the HTTP request and my hunch was confirmed, I signed in perfectly fine. That's when I knew something was interfering with the login endpoint. I decided to type in obviously fake credentials, and I took the entire response from that request into the same notepad. I copied both of the responses into a diff checker and noticed a few, subtle, tells straight away.

Prior to discussing those tells, I'll explain why I brought you back to where I was a few weeks ago. Today I'd come across something very similar to what happened to me a few weeks ago, and I did the exact same steps I did above. What caught me off guard is the EXACT same tells were present. This made me realize that I was actually dealing with some sort of anti-bot system/rule, so I tried to dig into the who question. The only two similarities they had regarding an anti-bot was Cloudflare Bot Manager cookies. I scoured through CF's docs to see if I could find anything to do with a deceptive fail, and I couldn't find anything. However, through tells I will get into below, the most likely scenario is the website's bot mitigation team have created a custom rule designed to waste attackers resources. This on the surface is a great idea, but only when it's indistinguishable from the real response, and that's why I'm writing this post today.

Both websites had ways to tell whether or not it's a "successful" response, and they were the exact same. When I diff checked the responses, the first header I saw that wasn't in the deceptive response was the Access-Control-Allow-Origin header. The more I looked, I started to realize that there was no origin application headers whatsoever in the deceptive response. No Access-Control-Allow-Origin, no Access-Control-Expose-Headers, and there wasn't a Cf-Cache-Status (Cloudflare sends this response header when the request is not blocked, and sent from the origin) header either. That strongly suggested the response was being generated upstream of the origin application. So there must be some sort of edge sitting in front of the origin that decides whether this request came from a human. And this ties back to the start, Cloudflare does not send Cf-Cache-Status headers for block pages, which I verified when I got an "Attention Required!" block. Not only that, the Cache-Control response header was:

private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0

When in the real response it was simply:

no-cache

This provided further evidence that the response was being generated upstream rather than by the origin application.

When I put the pieces together, I realized what the defenders were trying to accomplish. Having a deceptive fail makes the attackers think they've successfully automated the request, and it is a great idea. However, if an attacker can distinguish whether or not a request is fake, it completely defeats the purpose of the rule. Having a deceptive fail also comes with a cost. Paying customers, like I was a few weeks prior, don't get served a block page, they get told that their own account is invalid, which can drive off paying customers. On the other side of the coin, the naive attacker may waste a lot of resources thinking they've successfully automated the requests, but you as the defender, you're also wasting resources as the attacker will be repeatedly sending large amounts of requests to your website which is compute and bandwidth you're paying for.

Deceptive fails are great when applied properly, but if not done right, the attacker can simply diff check the responses and determine whether or not it is a deceptive response, which defeats the entire purpose of implementing one. If you're going to implement a deceptive fail, you have to make it byte identical to the real one across every header an attacker can observe. Same origin application headers, same cache control, and ideally minted at the same layer so it cannot structurally differ. If you can't guarantee an exact match, deception is worse than nothing, because it leaks and it false-positives your paying customers.

Modelling a deception response is one defence that most cannot build well without having sat in an offensive chair. This leak was obvious to me because I've spent most of my career trying to evade these systems, rather than building them. It makes you wonder, how many of these deception responses are one diff-check away from being rendered useless?