Request smuggling on a server that was not supposed to be online

The client gave us two mobile apps and one line of scope: see if you can break them. The apps turned out to be well built, so we came in another way.
Not through the phone, but through a development server that sat on the public internet with debug mode on and a proxy desync vulnerability. Inside we found a way to capture another user's request.
Below is the whole path, with a MITRE ATT&CK mapping and snippets to check off in your own setup.
Context
The mobile app was the most secure part of the whole puzzle. That is a rare sight. Usually the phone is exactly where we find leftover tokens, keys in files, data in an open clipboard. Not here. Data encrypted locally, all communication over HTTPS, signed binaries, debug mode off in the production build. Whoever built these apps did their homework on mobile security.
The client asked us for an external test of two native apps, on iOS and Android, and the domains tied to them. We worked grey-box: we got the API schema and test accounts, without source code and without elevated accounts. The backend is a REST API, the same layer serves both platforms, so on the logic side iOS and Android differed in nothing.
The challenge
Since the app itself was built properly, the interesting things had to sit somewhere next to it. A mobile pentest where the binary and the local data store are clean shifts the weight onto what the app talks to: the API, the proxy, the server configuration.
The mobile layer stuck to good practice. The catch was that next to the production version, a development copy of the same app stood on the internet. With a configuration nobody turns off, because the environment is not production. Django's debug mode was on, and one floor down, in the proxy layer, an HTTP request smuggling vulnerability. The defense was not foolish. It simply watched the front, and the way in was a side door that is easy to forget.
What we did
We started with recon of the domains tied to the app and quickly hit a development server. On every unhandled error it threw a full Django debug page. A page like that is the server's confession: it spills the framework and library versions, the list of installed components and middleware, disk paths, internal ports and services, and in one spot the address and login for an internal service that should not be visible from the internet. For a defender that is a list of things to fix. For an attacker, a ready map of the backend.
The real way in, though, was in the proxy layer. On the admin panel's login endpoint the server was vulnerable to request desync in the CL.TE variant. The reverse proxy reads the request length from the Content-Length header, the backend from Transfer-Encoding: chunked. When the two headers disagree, one request turns into two along the way, and the backend handles the second as if it came straight from the proxy, with no re-check.
POST /admin/login/?next=/admin/ HTTP/1.1 Host: dev.klient.example Content-Type: application/x-www-form-urlencoded Content-Length: 6 Transfer-Encoding: chunked 0 X
The proxy sees six bytes of body and lets the whole thing through. The backend ends the request at '0' and leaves 'X' as the start of the next request in the queue. That 'X' prepends itself to the request of the next person who lands on this server.
First we confirmed that we could force the server to send our request back inside its own response. On the panel login, the value of the email field and the redirect parameter came back in the response body. That meant the smuggled fragment could be read later. Then we reached for the endpoint that saves a user's profile data, captured another test account's request, and wrote its first 150 bytes into one of the fields. Put plainly: someone else's request fell into a field we could then read ourselves.
We did not take over the account. The scope ended exactly where it gets most interesting, and 150 bytes of someone else's request is not yet someone else's account. But we described the rest of the way step by step. The API had an endpoint that accepted a text field with a defined lower length bound and no upper one. That missing limit turns the field into a buffer: you can park a much longer smuggled fragment in it and read it back later, together with an authorization token belonging to another user. From there, account takeover is close.
The whole chain in MITRE ATT&CK terms:
Registration, by the way, gave away which email addresses were already in the system. A new address returned 201 and created an account, an existing address returned 400 with a note that the user already exists. Two different responses let you enumerate accounts outright: you can calmly build a list of real accounts and only then aim further attacks at it, password spraying among them.
POST /rejestracja HTTP/2
Host: dev.klient.example
Content-Type: application/json
{"email":"nieznany@klient.example"} -> 201 Created (konto założone)
{"email":"istnieje@klient.example"} -> 400 Bad Request (adres już w systemie)There was one more thing from the session layer: the authentication token could be reused after a long idle period. On its own a small item, but paired with capturing other people's requests it stretches the window in which a stolen token still works.
The result
The whole test produced one high risk and six medium, plus a longer tail of small items. The high risk is the chain described above: a public development server, debug mode, and proxy desync, which together give an outsider access to other people's requests. The mobile layer, the one the client worried about most, passed clean.
We left the recommendations as a checklist of actions, with a concrete step next to each item:
- Enforce HTTP/2 across the whole communication path, and where you have to drop to HTTP/1.1, validate the rewritten request against the spec and close the TCP connection on any ambiguity.
- Take the development server off the public internet, or hide it behind an IP allowlist or a VPN.
- Turn off debug mode on anything reachable from outside the internal network.
- Set server-side session invalidation on idle timeout and on absolute timeout, so an old token simply stops working.
- Make registration and login messages uniform, so the response for an existing and a nonexistent address looks identical.
- Put an upper length limit on the text fields the API accepts, so none of them can serve as a buffer.
This test shows a certain trap well. The team put a lot of work into mobile app security, and they were right to. The thing is, an attacker does not go where you put the most work. He goes where a forgotten server happens to sit with last quarter's configuration. How many of your development environments are reachable from the public internet right now? If you do not know that number by heart, let us find it together.
Book a call with a consultant. Together we will scope a test of your mobile apps and their backend (API, proxies, dev servers) and what a chain like this could look like in your environment.
All case studies are anonymized by sector, without names, dates or any data that could identify the client, in line with confidentiality. We never publish real vulnerabilities or client technical data.