Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

I Reverse Engineered an Android App With Permission. Here's What I Learned.

A while back I got permission to test a consumer Android app and its backend. The app talked to a REST API and paired with a Bluetooth device. I tested against my own account and hardware I was allowed to touch, and ever

I Reverse Engineered an Android App With Permission. Here's What I Learned.

A while back I got permission to test a consumer Android app and its backend. The app talked to a REST API and paired with a Bluetooth device. I tested against my own account and hardware I was allowed to touch, and every state-changing action sat behind an explicit confirmation prompt. This post is the sanitized version of that engagement: what I did, what broke, and what I would do differently. Names, hosts, identifiers, and exact protocol details are stripped or changed. The lessons are not.

The rules I set before touching anything

Authorization first, scope second. I wrote down what I could test (my account, devices I owned or had written permission to use), what I would not touch (other users, production data, anything that could affect a third party), and what I would do with findings (private report, no public details until the fix ships). Having that written down sounds bureaucratic. It saved me twice, once when a test looked harmless and was not, and once when the tooling offered more data than I needed.

Step one: get the APK off a non-rooted phone

No root, no custom recovery. That means the app's private data is off limits, but the APK itself is not. A stock Android device will hand over the installed package over ADB. I pulled the base APK, checked its signature, and noted that it was a release build with debugging disabled. That last part matters. A release build changes the plan: no attach and inspect, no debuggable sandbox, no easy instrumentation. Everything from here is static analysis plus black-box testing.

The other early decision: do not repackage the original app. It is tempting. Repackaging breaks the signature, and if the login flow is tied to the package name and signing certificate, you end up with an app that cannot log in. I learned that the slow way and built a separate harness instead.

Step two: figure out what kind of app this is

The Java layer looked thin, which is a tell. The interesting logic lived in a native library: a Flutter app. That changes the toolchain. The Dart code is compiled ahead of time, symbols are stripped, and class names are not going to hand you a map. I still got useful signal from strings, resource tables, and the platform shell. But most of my understanding came from watching the app talk to its backend and from forcing the API to describe itself.

Step three: get past the login wall the boring way

The app used one-time codes and a third-party sign-in flow. The sign-in token was bound to the app's package name and signing certificate, so a modified client could not reuse the original session. Fine. The email code path worked, and I logged into the backend with my own account.

Then I hit my first real wall: every authenticated request came back with a version error. Not 401. Not 403. The server wanted an app version header, and without it, protected endpoints refused to work. Once I sent the same headers the real app sends, the API opened up. Lesson: auth is not just a token. Version, build number, and platform were part of the contract.

Step four: let the API explain itself

I did not have a clean request factory to read. So I treated the API as a black box and used its error messages as documentation. Send a request with a missing field and the server tells you which field it wanted. Send a bad type and it tells you the type. Send an empty body and you get a schema-shaped complaint. Slowly, the surface came into focus: session endpoints, a read endpoint that returned a list of objects, a detail endpoint that sometimes returned 404 for objects the list never showed, and a few write endpoints that accepted small JSON bodies.

One pattern showed up quickly. The mobile UI displayed a filtered view of the data. The list endpoint did the filtering, which is fine. But at least one action endpoint did not repeat that rule. It accepted identifiers for objects the list hid and the detail endpoint returned 404 for. In other words, the UI enforced a rule the server did not. That is the classic gap between "the user cannot click it" and "the server will refuse it." The client was doing UX, not authorization.

I wrote it up as a broken function-level authorization finding. No public proof of concept, no list of live targets, no step-by-step.

Step five: decode the Bluetooth protocol

The app also paired with a device over Bluetooth Low Energy. The GATT layout was straightforward once I dumped the services: a serial-style service with a write characteristic and a notify characteristic. The payloads were not plain text. They used a small framing header, a length field, an XOR layer, and a checksum. The checksum was the fun part. I had a companion SDK to compare against, which saved me from a long night of brute force, and I still verified the result against frames I captured myself.

The important part was not the checksum. It was the acknowledgment that the BLE link was a second, separate trust boundary. The backend could authorize a command, but the device still had its own rules, its own key exchange, and its own failure modes. Testing only the API would have missed half the picture.

Step six: build a harness, not a weapon

Once I understood the flow, I built a small standalone Android app that could log in, list the resources my account could see, and exercise the API calls I cared about. It kept the login and the read views. Every write action sat behind a confirmation dialog that named the target. The harness logged request and response shapes so I could paste evidence into the report. It was not a modified copy of the original. It was a separate test client with its own package name.

That design choice mattered for two reasons. First, it kept the original app untouched, so I could compare behavior side by side. Second, it made the destructive parts deliberately inconvenient. A harness that can fire twenty actions with one tap is a liability, even when you have permission.

The line I did not cross

Partway through, I found hints that some endpoints might expose more than the UI showed. Some of it was harmless. Some of it could have identified or located other people using the service. I stopped there. Permission to test my account is not permission to surveil someone else's. A finding that requires third-party data is not a finding I need to prove. I noted the boundary and moved on.

That is not a moral victory lap. It is a practical rule. Once you start pulling other people's data into a test environment, every screenshot, log line, and backup becomes a liability. The cleanest report is the one that never had the data in the first place.

What actually slowed me down

  • Release builds and stripped symbols. No debuggable build, no readable Dart names. You earn every function name.
  • Package and signing binding on login. Repackaging the app was a dead end. A separate harness was the right call.
  • Required headers that are not auth. The version gate looked like a broken API until I matched the real client.
  • Error-driven schema discovery. It works, but it is slow and noisy. Keep a request log.
  • Bluetooth framing. Checksums, byte order, and notify timing eat hours. Capture more frames than you think you need.
  • Tool switching. Static analysis tells you where to look, but black-box testing tells you what the server actually does. Neither one alone was enough.
  • Sleepy hardware. I once spent twenty minutes debugging a blank screen before realizing the phone had gone to sleep. Check the boring causes first.

What I would tell someone starting the same project

  1. Write the scope and the stop rules before you install anything.
  2. Pull the APK and check the signature first. It sets the whole approach.
  3. Identify the app framework early. Flutter, React Native, and native Android each fail in different ways.
  4. Treat the API as a source of truth. The UI is a suggestion.
  5. Log every request and response from day one. The report writes itself later.
  6. Build a separate harness with confirmations. Do not modify the original unless you have a very good reason.
  7. Draw the third-party line early, not when you are already holding the data.

Everything sensitive stays out of this post on purpose: hosts, identifiers, request bodies, and the exact reproduction. If you are writing up your own engagement, keep it that way until the fix ships.

The takeaway that stuck with me is smaller and more useful: the distance between what an app shows you and what its backend will accept is where a lot of real findings live. You do not need a zero-day to find it. You need permission, patience, and a good log.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.