A General Methodology for Reversing Deeply Obfuscated Modified MD5

About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.

This research is published for educational and defensive purposes. The target sample is anonymized throughout.

This article documents a general-purpose playbook for reversing a deeply modified MD5 implementation hiding inside a heavily protected Android app. What makes this case interesting is not MD5 itself — it’s that the modification points are numerous, the code has been run through a customized OLLVM pass, and a surrounding HMAC forces multiple interleaved MD5 passes. Most of the usual static-analysis tricks stop working here, so the whole effort pivots onto instruction tracing and regex-driven automation.

This writeup focuses on the core reverse-engineering methodology, so it should transfer to other apps and other hash families (MD/SHA) that share the same structure.

The situation

The target is a request-signature parameter generated in native code. The signing algorithm is a modified MD5 with these characteristics:

  1. The four initial state words (abcd) are modified — but only partially swapped. The challenge is confirming you’ve picked the right ones.

  2. The 64-round function is fully unrolled by an aggressive OLLVM pass. IDA is effectively unusable for reading it.

    Unrolled OLLVM-obfuscated 64 rounds

  3. The T table is completely replaced. You can locate it by finding the load-offset pattern in the trace, then dump it with an IDA script — or extract it from the trace with a regex script.

  4. The T table is shuffled across rounds. Even after dumping, certain rounds (say, 39 and 40) have their tables swapped, so the table “flies around” and you have to re-synchronize it.

  5. Because the shuffle also affects abcd differently per round, simply swapping the T table is not enough — you sometimes need the fully unrolled form of MD5 to express it.

  6. Every variable in the MD5 — abcd, f, intermediates — lives at a single address accessed as table-lookup-plus-offset (v1+496, v+520), and the offset moves, like a sliding window. Hand-reading this is a dead end.

  7. To compute one hash, the code runs MD5 over multiple 64-byte blocks. You have to figure out the length field past byte 80 to determine the block count, then reconstruct how the multiple passes chain together.

  8. A useful invariant: the first pass and the second pass share the same T table in round 1 of their 64 rounds. That gives you an anchor to locate the PC where the plaintext is loaded, which lets you write a batch script to extract the plaintext of every pass.

    Round-1 T-table anchor

  9. Individual rounds still deviate — because the passes are interleaved, not strictly sequential. In the trace you’ll see (in this case) five MD5 passes; you have to determine which ones are contiguous and reconstruct the order purely from the trace.

  10. Recognizing the HMAC up front is optional for the algorithm itself — you can treat it as two hashes and two salts — but knowing it saves time and yields a cleaner reconstruction.

HMAC recognition

Bottom line:

✔ Static analysis is basically dead ✔ You must rely on tracing ✔ You need automation

Locating the signature parameter

First, capture traffic and inspect the UA. If you see an OkHttp signature, the client is very likely OkHttp; the same logic usually applies to plain HTTP clients.

The header parameter in question appears on many requests, so it almost certainly isn’t added per-request — it comes from a reusable header-adding path. In OkHttp that’s the classic interceptor mechanism: a pipeline where every request flows through the same chain and gets decorated the same way.

To find which interceptor adds the header: first hook the interceptor list, then hook each interceptor’s actual intercept method and print the headers on entry and exit (or diff them). That tells you “interceptor 1 added shie**, interceptor 2 added x-mini-sig”.

Interceptor hook output

In the native layer you can drive the same logic with Unidbg. The main friction is the OkHttp framework itself, which you pull in via the Unidbg pom.xml:

Unidbg with OkHttp

For this sample, the cleanest path in Unidbg is to run jnitrace and stub out the missing pieces from its log, iterating until the signature parameter shows up in some JNI log. It takes patience.

Key data is split into parts

Skipping the byte-level analysis of the earlier segment: the value is a 16 + 83-byte structure. The trailing 83 bytes come out of an RC4 layer; that 83-byte blob is itself split into several parts, and the last 16 bytes are the modified MD5.

16 + 83 byte split

A general playbook for deep-obfuscated MD5

You must trace first. The rest is a sequence of concrete recovery steps.

1. Locate the initial abcd

Initial abcd

Two anchor points give you the initial state:

  1. First-round participation — the first occurrence participates in round 1 of the 64 rounds. The subtlety is proving your a isn’t actually d, and b isn’t c. There are extra features to cross-check, but the next anchor is the decisive one.
  2. Final accumulation — the second occurrence is added to the post-64-round state at the end.

The key insight: you already know the final 16-byte digest, and the final abcd is the 64-round state plus the original abcd. That disambiguates which value is a, which is b, and so on.

For example, take a digest ef29bddaca2a67b2dcff858ad588a2b6. From endianness and MD5’s layout you know dabd29ef is a, and that a is the sum of two values. So in the assembly you regex-search:

add.*=>.*=0xdabd29ef

That yields two candidates — say 0x9bccab01 and 0x3ef07eee. One is the original a and the other is the 64-round-computed a. Once you’ve recovered all four initial abcd, the candidate set tells you which is which.

Accumulation trace log

The discriminating checks:

  • Does it participate in round 1?
  • Does it participate in the final accumulation?
  • Does it satisfy the T / f / M relationship?

2. Recover the plaintext (even in the “know nothing” case)

When you genuinely know nothing, your breakout points are f_old, T, and the plaintext. Assume everything is modified, and break the plaintext first.

The method:

  • Scan for the 0x80 padding byte
  • Locate the write site

0x80 padding scan

HMAC double-hash caught

In this app’s case there’s an HMAC, so there are two hash passes — and both were caught by the padding scan.

The same approach adapts to MD-family and SHA-family hashes that use the Merkle–Damgård padding structure. The essential MD5 fingerprint is: there is always a 0x80, unless the implementation modified that byte too.

Other large-app examples

Another example

A note on practicality: a naive scan can dump 2000–3000 log lines, which defeats the purpose. Add filters to keep the output meaningful. In practice this is the kind of thing you let an AI write for you, then tighten the conditions until it’s stable.

Once you know where the 0x80 is written, you can jump to that PC/LR and locate the modified-MD5 function entry — from there, hook it to capture input and output.

3. Recover the T table

With the plaintext and a in hand, the remaining unknowns are f_old and T. The rule of thumb: whatever is produced by &, ^, | is f_old; whatever is loaded by a fixed ldr is very likely T.

From the earlier example, we had:

[lib[redacted].so 0x12000000+0x84010] 0x12084010: "add w8, w11, w8" ; w11=0x9bccab01 w8=0x3ef07eee => w8=0xdabd29ef

Here 0x9bccab01 and 0x3ef07eee — one of them is b. Scroll up from this point:

Scrolling up to the f computation

The first value turns out to be produced by a rotate (ror), which makes it f. So 0x3ef07eee is b, and 0x9bccab01 is f shifted left from 0xb025f196.

Recall the MD5 round update:

f = f_old + a + T + M[g]

You now know a, M (the plaintext), and f, so T is determined.

From here, if you know the T values of rounds 1, 2, 3, you can use the ldr load-offset pattern to find the original load site and dump all 64 T values at once. If the 64 rounds share one code path, you can search the next round’s T load by PC; in this sample the 64 rounds are fully unrolled, so every PC differs and you have to find the pattern instead.

You can also derive b here: from round 1’s abcd you get f_old (or recover it by &/^/| elimination); with T, M, and the left-shift known, you compute round 1’s new b.

4. The relationship between the 64 rounds

The 64 rounds of MD5 are, in essence, generating 64 new b values. Open the MD5 function and emit per-round logs, then search the trace for those b values. If a b matches, everything up to that round is almost certainly correct; if not, binary-search to find the first round where it diverges.

While doing this, check each of: whether the f function is modified, whether the shift amounts are modified, whether the T table is modified, and whether the plaintext blocks are interleaved. In this sample everything was modified except the f function, so you genuinely need this stepwise error-localization technique.

Round output logs

The recurrence is:

new_b = previous_b + (f <<< s)

If you know the previous b, you can regex-search add.*<previous_b>.*=>.* to find where the next b is produced. For example, if the previous b is 0x7e9b89fb:

add.*0x7e9b89fb.*=>.*

There will be two matches because abcd rotate through the rounds — one candidate participates in producing f, the other is the true next b. Disambiguate by checking which f value came from a left-shift.

5. Regex extraction of shift amounts and T table

Reversing all 64 rounds by hand is exhausting. The trick is to hand-reverse ~20 rounds, then script the rest as constant-dumping.

The shift amounts follow a recognizable pattern: f is prepared by a left-shift, then added to the previous b. Write a regex that matches that assembly block (allowing intervening instructions — the pattern should be smart about that), then extract the shift amounts.

Shift-amount pattern

In this sample the initial match produced 250+ candidate shift amounts. You take the hand-reversed ones (6, 11, 23), find them among the candidates, and slice out 64 consecutive values. If that fails, your matcher is wrong — but even a partial match (first 10 correct, last 6 not contiguous) is salvageable: reverse the first few of the remaining ones, then slice again.

Caveat: because this sample interleaves multiple MD5 passes, the shift amounts are interleaved too, which is why the patch-and-slice approach is needed. A normal (non-interleaved) sample would just match cleanly. And if the 64 rounds weren’t OLLVM-unrolled, the per-round PC addresses would be predictable enough to read each shift amount directly.

The T table can be extracted the same way.

6. Extracting the plaintext of each MD5 pass

Per-pass plaintext

The plaintext is used in every round to compute the g index that slices out one word. In round group 1, g is just the loop index (0–15), so four bytes at a time reconstructs the full 64-byte block.

Reconstructed 64-byte block

Each plaintext block is used four times across the four round groups (that’s how MD5’s g schedule works). Crucially, the ldr that loads each plaintext word sits at the same PC every pass — so you search for that PC and read the register value to pull out each pass’s 64-byte block.

7. Block-chained (multi-block) MD5

In the reference implementation, every 64-byte block updates self.A/B/C/D, and the next block’s round 1 uses the previous block’s abcd as its initial state. So three MD5 passes means the first two are 64 bytes each, and the third carries the 0x80 padding plus the 8-byte length field.

You can infer the block count from the length field: here the tail length field was 508 → 0x508 / 8 = 161 = 128 + 33, i.e. two 64-byte blocks plus one 33-byte block.

Length field

This is also why a naive 0x80 memory-scan plugin misses the full plaintext: the first two 64-byte blocks aren’t MD5-padded, so the scan never sees their plaintext.

The fix is to remove the MD5 padding, use the pre-padded 64-byte blocks, and fix the length field correctly — e.g. ...64 bytes... 80 00..00 08 05 00 00 00 00 00 00 where the tail holds the correct length.

Then: find the initial abcd in the trace, reduce the modified MD5, and use the plaintext-extraction script to pull each pass’s 64-byte plaintext and initial abcd. Run each, and chain the results.

The structure is a chain:

plaintext1 -> MD5 -> abcd1
abcd1 -> plaintext2 -> MD5 -> abcd2
abcd2 -> plaintext3 -> MD5

abcd1 becomes the initial state of the second MD5, abcd2 of the third. Concatenate the three plaintext blocks and run the algorithm from the first initial abcd.

Interleaved multi-block passes

In this sample there are two MD5s, one with three 64-byte blocks and one with two — and the passes are interleaved. Strip the 0x80, the zero padding, and the length field from the third block and you have the plaintext. This generalizes to any MD5 over >64-byte inputs, and to HMAC (which inherently exceeds 64 bytes because ipad/opad are each 64 bytes).

8. HMAC recognition

In plain terms:

  • two hash passes
  • two salts (ipad and opad)

Which you can just treat as multiple MD5 passes during reconstruction.

The complete workflow, condensed

  1. Locate the initial abcd
  2. Scan for 0x80 and extract the 64-byte plaintext written before it
  3. Start from the standard algorithm and modify it
  4. Compare round-by-round to localize each modification point
  5. For multi-block inputs, find the initial abcd (treat it as the modified IV), reverse the last block first, strip the padding, and work backwards through each preceding block until the plaintext length (from the bit-length field) is accounted for

The core skills this leans on:

  1. trace analysis
  2. regex extraction
  3. automation scripts
  4. binary-search error localization across the 64 rounds

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).

Get in touch