One Missing Check, Total Exposure: How a Single API Flaw Exposed Every Record Across 3 Apps
One number, changed in a request, returned a different member’s file. Then it let us edit that file, close it, and download the identity documents attached to it. It worked identically from the web app, the iOS app and the Android app, because the check that should have stopped it was missing from the one thing all three of them share - the API underneath.
- The system
- A membership platform. Members sign in to raise and track cases with the organisation, attach supporting documents, and download documents the organisation issues to them.
- In scope
- Three client applications - web, iOS and Android - and the one API all of them call.
- What was at risk
- Every member record on the platform: case correspondence, the identity documents attached to it, and the documents the organisation issues to named individuals. Readable, editable and deletable by anybody holding an ordinary account.
Anonymised. An assessment carried out by our testers, with client-identifying detail withheld under the terms of the engagement. Full statement
What we were given
A membership organisation ran one product across three client applications - a web application, an iOS app and an Android app - and one REST API that all three of them called. Members signed in to raise cases with the organisation, track them, attach supporting documents, and download documents issued to them individually.
The data was ordinary and sensitive in the way most member data is: correspondence about individuals’ circumstances, identity documents attached in support, and files that exist specifically to say something official about one named person.
All three applications and the API were in scope, which is less common than it should be. The usual version of this engagement covers the web application because that is the thing the buyer can see, and the scope conversation treats the mobile apps as a separate project for a later quarter.
Getting to the traffic
Both mobile apps pinned their certificates, so the first task was seeing what they sent. On a device under our control this took standard tooling and part of an afternoon.
Pinning is a speed bump
Certificate pinning stops an attacker on the network reading a user’s traffic, which is worth having. It does not stop the person holding the phone from reading their own traffic, because they control the device the check runs on. Any control implemented in the client is negotiable by whoever owns the client. Treat pinning as friction, and put the decisions on the server.
What the traffic showed was unremarkable and is the reason the rest of this happened: plain REST calls, each carrying an identifier for the record being acted on, with the signed-in member’s token in the header.
What we found
Changing the identifier to one belonging to a different member returned that member’s record. The server checked that the caller was signed in. It never checked that the record belonged to them.
That is authorisation bypass through a user-controlled key, and it is the single most common serious finding we report. The application had authentication and no authorisation: it knew who you were and did not use it.
It was not only reads
The same substitution worked on every action the interface offered. Another member’s record could be updated. It could be closed. Its attachments could be downloaded. And the document the organisation issues to an individual member, the one whose entire purpose is to be evidence about that person, could be retrieved by anyone able to work out an identifier.
Identifiers of this kind are rarely hard to work out, and "hard to guess" was never the control anyway. They enumerate when they are sequential, and they leak through shared links, exports and support tickets even when they are not.
Why it appeared in all three
Because the check was missing in the API, it was missing everywhere. The web application, the iOS app and the Android app were three front doors onto the same open room. The mobile findings and the web findings were not four problems that happened to look similar. They were one problem, seen from four places.
Two more that mattered
Signing out did not sign you out
Logging out cleared the application’s local state and left the session valid on the server. A token captured before logout kept working afterwards. On a shared, sold or lost device that turns "I logged out" into a false statement, and it is the failure mode people are least prepared for because the interface told them the opposite. There is more on this in the guide on what logout has to do server-side.
Unlimited password reset emails
The password reset endpoint would send mail to a chosen address with no cap. That is not a compromise on its own. It is a way to make a real person’s inbox unusable at the moment they need it, and a way to make a phishing message arriving in the middle of that flood look expected. The rate limiting guide covers the trap in fixing this badly.
How far it went
One missing check exposed every member record on the platform, on every surface, for reading and for modification. There was no privilege escalation to chain, no exploit to write, no unusual condition to arrange. An ordinary member account and a changed number in a request was the whole of it.
The commercially important part is what would have happened with a narrower scope. Test only the web application and you find the flaw, fix it in the web tier, and leave two mobile apps calling the same unprotected API while believing the problem is closed. Test only the mobile apps and you get the same result the other way around. The finding is cheap to fix once and expensive to fix three times, and only one of those outcomes leaves you actually fixed.
The fix
- Authorisation moved into the API, checked per record, on every action rather than on reads alone. The question the server has to answer is not "is this caller signed in" but "does this caller own this record".
- Sessions invalidated server-side at logout, so the token stops working when the user says it should.
- Password reset limited by source and by destination address, with the destination cap failing slowly rather than failing closed.
- The document download path treated as a distinct authorisation decision rather than a file served from a known location.
Verification matters more than usual on this one. A fix applied at a single call site looks identical, from the outside, to a fix applied in the right place, and the difference only shows on the application nobody retested. Our engagements include a retest of the findings once they are fixed, and the retest here has to repeat the substitution in all three applications, not in the one where it was first reported. How we run an engagement has the sequence.
The findings, in one place
- The same authorisation flaw appeared in all three applications, because the check belonged to the API underneath them and the API never made it.
- Certificate pinning delayed the assessment by an afternoon. It is a speed bump, not an access control, and it should not be counted as one.
- The flaw worked on writes as well as reads: another member’s record could be updated, closed and its attachments downloaded.
- Signing out cleared the app and left the session valid on the server, so a returned or lost device stayed signed in.
- A web-tier fix would have left the mobile apps exposed and the client believing the problem was solved.
What to check on your own system
If your web application and your mobile apps talk to the same API, you do not have three applications to secure. You have one, with three ways in. Scope the API. The clients in front of it are worth testing for what only they can get wrong, which is real but smaller: data left on the device, controls implemented locally, secrets shipped in the bundle. The decisions live on the server, and so do the findings that end up mattering.
Confidentiality
Anonymised. This describes an assessment carried out by our testers. The client, the dates, the sector detail and any figure that could identify the organisation are withheld or generalised under the confidentiality terms of the original engagement, and no endpoint, parameter or payload from the assessment appears here.
Keep reading
- How to scope a penetration testWhat to put in scope, what it costs to leave out, and the questions a tester needs answered before quoting.
- What a penetration test actually findsThe classes of flaw that come up most, and which of them a scanner will never report.
- Session management and logoutWhy clearing local state is not signing out, and what the server has to do instead.
- How we run an engagementThe sequence from scoping call to retest, and what you get at each step.