Skip to content

Conclusion & Defensive Recommendations

What This Lab Demonstrates

The core finding across this entire lab is not any individual vulnerability — it is a broken trust model that runs through every layer of the application.

The app treats the client as a trust boundary. Configuration that should live on the server is hardcoded in the binary. Protections that should be enforced on the backend are implemented client-side where the attacker has full control. Authorization checks that should be uniform across all operations are applied inconsistently, or not applied at all.

Every finding in this lab is a consequence of that same underlying assumption: that the client can be trusted to enforce rules, carry secrets, and make authorization decisions on behalf of the server. It cannot.


Defensive Recommendations

These are organized by layer, from the highest-impact fixes to the secondary hardening measures.


Backend — Fix First

Object-level authorization on every endpoint. GET /todos/{id} returns data to any authenticated user regardless of ownership. The fix is straightforward: before returning a resource, verify that the requesting user owns it or has explicit permission to access it. This check needs to be present on every read operation, not just write operations. The inconsistency in this app — writes protected, reads not — is a common pattern that comes from implementing authorization per-operation rather than as a policy applied uniformly to the resource.

Field-level authorization on update endpoints. PUT /users/{id} accepts a role field and updates it without checking whether the requesting user has the authority to change their own role. The fix is to define which fields are user-updatable versus admin-only, and enforce that at the service layer before the update reaches the database. Accepting arbitrary fields from the request body and passing them through is the root cause — input should be filtered to only the fields the current user is allowed to modify.

role as a JWT claim. The current design encodes role in the JWT payload and relies on that claim for authorization decisions. This means that if the secret is weak or the token is forged, role can be manipulated at the token level. A safer pattern is to not encode role in the token at all — look up the user's role from the database on each request using the user ID in the token. This adds a database lookup per request but eliminates the entire class of token manipulation attacks.

Remove the x-admin-token pattern. A shared static secret used as an admin credential is not an authorization mechanism — it is a password hardcoded into a binary that every user downloads. Replace it with proper authentication for admin operations: admin users should authenticate with their own credentials and receive tokens scoped to their role, same as regular users.

Tighten validation error responses. The PUT /users/{id} endpoint returns "role" must be one of [user, admin] in its validation error. This is a minor issue but worth fixing: error responses should indicate that a field is invalid without enumerating what valid values look like. Generic validation errors — "invalid input" — give attackers less to work with.


Mobile Client — Harden After

These are worth doing, but only after the backend authorization issues are fixed. Hardening the client without fixing the backend changes the difficulty of the external attacker path while leaving the legitimate user path completely unaffected.

Remove hardcoded secrets from the binary. The API base URL, SSL pinning fingerprint, and x-admin-token are all embedded in libapp.so. The URL and fingerprint can reasonably live in the binary — they are not secrets. The admin token cannot. Any value that grants privileged access to the backend should never be in the client binary. There is no obfuscation level that makes a secret in a distributed binary safe from a motivated analyst.

Move JWT storage to encrypted storage. SharedPreferences stores data in plaintext XML on the filesystem. On a rooted device, this file is directly readable by anyone with a root shell — no network access, no bypass required. Flutter has the flutter_secure_storage package which uses the Android Keystore to encrypt stored values. For tokens specifically, this is the appropriate storage mechanism.

Understand what SSL pinning and root detection actually protect. Both are useful for raising the bar against passive attackers and making traffic interception less trivial. Neither is a hard security boundary. Root detection can be hooked via Frida. SSL pinning can be patched at the engine level. Treat them as friction, not protection — and do not rely on them to compensate for backend authorization failures.


Closing Thought

The most expensive finding in this lab to exploit was the SSL pinning bypass — it required identifying the Flutter engine version, tracing the BoringSSL commit, locating the verification function in a stripped binary, and patching four bytes in the right place. That is real work.

The highest-impact finding — privilege escalation to admin — required sending a single PUT request with "role": "admin" in the body. No reverse engineering, no binary patching, no Frida. Just a proxy and a valid account.

That asymmetry is worth sitting with. The client-side protections in this app are genuinely non-trivial to bypass. But they protect against one threat model while leaving a more accessible path completely open. An application is only as secure as its weakest boundary, and in this case that boundary is the backend — which trusted the client to enforce rules the server should have owned from the start.