Skip to content

synchronized block translation breaks ART structured-locking: catch-all no longer covers the monitor-exit, producing monitor held outside catch-all VerifyError after re-dexing #72

Description

@jscho3618

Summary

When dex2jar translates a method that contains a Kotlin/Java synchronized block, the reconstructed .class does not preserve the structured-locking shape that the original DEX had. Specifically, dex2jar reloads the lock object (via a fresh ldc <class>) immediately before each monitorexit, and the generated exception table's catch-all (any) range ends before that reloaded monitorexit tail. The instructions sitting between the end of the catch-all range and the monitorexit are therefore covered only by a typed handler (InterruptedException), not by the catch-all.

The intermediate .class still "works" on a desktop JVM after a verifier recompute, so the defect is easy to miss. However, when the .class is converted back to DEX (e.g. d8/r8 in a re-obfuscation or re-packaging pipeline), the register allocator binds monitorenter and monitorexit to different registers (monitor-enter v1 / monitor-exit v0), and the catch-all gap is carried verbatim into the DEX. Android's ART verifier then rejects the class at load time with a structured-locking error (monitor held ... outside of a catch-all / unbalanced monitor), and the app crashes with java.lang.VerifyError.

This report uses okio.AsyncTimeout$Watchdog.run() (Kotlin, a plain synchronized block) as a minimal, real-world reproducer. The behavior was confirmed on two dex2jar builds (dex-translator-v2.4 and the maintained fork dex-translator-2.4.36) — the final DEX is byte-identical between them, so this is an architectural translation issue, not a version regression.

Environment

  • dex2jar versions tested: dex-translator-v2.4.jar and dex-translator-2.4.36.jar (maintained fork). Both reproduce identically.
  • Input: a release (non-R8) Android APK built with AGP/D8; classes.dex contains okio.AsyncTimeout (okio 2.x, Kotlin).
  • Re-dexing tool: d8 3.3.20 and d8 8.x — both carry the defect (d8 is faithful, see below).
  • Disassembler: Android dexdump (build-tools 28.0.3) and javap -v.
  • Pipeline: APK → unzip classes*.dex → dex2jar → .class/.jar → (optional obfuscator) → d8 → classes.dex → repack. The obfuscator (Allatori) was verified not to be involved — the final DEX is identical with and without it.

The Original DEX (known-good, runs on device)

This is the Watchdog.run() method as compiled by the Kotlin compiler + D8 in the original, unmodified APK. It runs correctly on ART. Note that the lock object lives in a single register v1 for the entire critical section, and every monitor-exit releases that same v1:

0004: const-class v1, Lokio/AsyncTimeout;
0006: monitor-enter v1                 ; lock held in v1
0007: sget-object  v2, ...Companion
0009: invoke-virtual {v2}, ...awaitTimeout$jvm
...
0019: monitor-exit v1                  ; release SAME v1 (no reload)
001a: return-void
001b: sget-object  v0, kotlin/Unit.INSTANCE
001d: monitor-exit v1                  ; release SAME v1
...
0024: move-exception v0
0025: monitor-exit v1                  ; release SAME v1 (catch-all handler)
0026: throw v0

catches : 5
  0x0001 - 0x0007  -> Ljava/lang/InterruptedException; @ 0x0000
  0x0007 - 0x0019  -> <any> @ 0x0024          ; catch-all ENDS EXACTLY AT monitor-exit (0x0019)
  0x0019 - 0x001a  -> Ljava/lang/InterruptedException; @ 0x0000
  0x001b - 0x001d  -> <any> @ 0x0024          ; catch-all ENDS EXACTLY AT monitor-exit (0x001d)
  0x001d - 0x0027  -> Ljava/lang/InterruptedException; @ 0x0000

Key property: every offset at which the monitor is held is covered by the <any> catch-all, and the catch-all range boundary coincides exactly with the monitor-exit. This is what ART's structured-locking verifier requires.

What dex2jar Produces (the .class)

dex2jar (both v2.4 and 2.4.36) translates the same method to this JVM bytecode. Class file major version: 52. The lock object is reloaded with ldc <class> immediately before every monitorexit, and the exception table's any ranges stop short of that reload+exit tail:

 5: ldc           #6        // class okio/AsyncTimeout
 7: monitorenter
 8: getstatic     #41       // okio/AsyncTimeout.Companion
11: invokevirtual #47       // Companion.awaitTimeout$jvm
...
31: ldc           #6        // RELOAD class okio/AsyncTimeout   <-- inserted by dex2jar
33: monitorexit
34: return
35: getstatic     #60       // kotlin/Unit.INSTANCE
38: astore_1
39: ldc           #6        // RELOAD class okio/AsyncTimeout   <-- inserted by dex2jar
41: monitorexit
...
53: astore_1                // catch-all handler
54: ldc           #6        // RELOAD class okio/AsyncTimeout
56: monitorexit
57: aload_1
58: athrow

Exception table:
   from   to  target  type
      0    8    59    Class java/lang/InterruptedException
      8   31    53    any        <-- catch-all ENDS AT 31, but monitorexit is at 33
     31   34    59    Class java/lang/InterruptedException   <-- the ldc(31)+monitorexit(33) tail is typed-only
     35   39    53    any        <-- catch-all ENDS AT 39, but monitorexit is at 41
     39   42    59    Class java/lang/InterruptedException   <-- the ldc(39)+monitorexit(41) tail is typed-only
     46   50    59    Class java/lang/InterruptedException
     54   59    59    Class java/lang/InterruptedException

The two structural regressions

  1. Reload-per-exit instead of a single held reference. The original DEX keeps the lock object in v1 and exits on v1. dex2jar discards that dataflow fact and re-materializes the class constant (ldc #6) before each monitorexit. This is the proximate cause of regression maven releases #2.

  2. Catch-all coverage gap. Because the ldc was inserted before the monitorexit but the any range was left pinned to the pre-insertion boundary, there is now a window (offset 31..33, offset 39..41) where the monitor is still held but is covered only by an InterruptedException handler — not by the catch-all. If anything other than InterruptedException is thrown there (e.g. class-resolution failure on the ldc, an async exception, IllegalMonitorStateException), the monitor would leak. ART forbids exactly this shape.

What d8 Produces After Re-dexing (the failing DEX)

Re-converting the dex2jar .class to DEX with d8 faithfully encodes both regressions. d8 is not at fault — it correctly register-allocates the reloaded constants, which is precisely what makes the breakage observable:

0004: const-class v1, Lokio/AsyncTimeout;
0006: monitor-enter v1                 ; lock held in v1
...
0019: const-class v0, Lokio/AsyncTimeout;   ; reloaded into v0 (NOT v1)
001b: monitor-exit  v0                  ; release v0  <-- register mismatch vs enter v1
001c: return-void
001d: sget-object   v0, kotlin/Unit.INSTANCE
001f: const-class   v0, Lokio/AsyncTimeout;
0021: monitor-exit  v0                  ; release v0
...
0028: move-exception v0
0029: const-class   v1, Lokio/AsyncTimeout;
002b: monitor-exit  v1
002c: throw v0

catches : 5
  0x0007 - 0x0019  -> <any> @ 0x0028     ; catch-all ENDS AT 0x0019, but monitor-exit is now at 0x001b
  0x0019 - 0x001c  -> Ljava/lang/InterruptedException;   ; the const-class(0x0019)+monitor-exit(0x001b) tail
  0x001d - 0x001f  -> <any> @ 0x0028
  0x001f - 0x002d  -> Ljava/lang/InterruptedException;

Compared to the original DEX:

  • Register mismatch: monitor-enter v1 vs monitor-exit v0. The original used v1 throughout.
  • Catch-all gap: in the original the <any> range ended exactly at the monitor-exit; here it ends at 0x0019 while the monitor-exit has moved to 0x001b, leaving the const-class/monitor-exit tail outside the catch-all.

Runtime Impact

On Android, ART's verifier enforces structured locking (every held monitor must be released on every path, including the exceptional path, via a catch-all that the verifier can prove balances the lock). The DEX above violates that invariant, so loading the class throws:

java.lang.VerifyError: ... okio.AsyncTimeout$Watchdog.run ...
  monitor-exit on non-monitor / monitor held at exit not covered by catch-all

The process crashes at class-load time. This affects any app that round-trips DEX → .class (dex2jar) → DEX (d8/r8) — common in re-obfuscation, instrumentation, shrinking, and re-packaging pipelines — and that contains any synchronized block with more than one exit edge (return inside the lock + the implicit exception path). okio.AsyncTimeout$Watchdog is just the first class to trip it; the pattern is generic to Kotlin/Java synchronized.

Reproduction Steps

  1. Take any APK containing okio.AsyncTimeout (okio 2.x) — or any class with a synchronized block that has an early return inside the lock.
  2. Extract classes.dex and run d2j-dex2jar.sh classes.dex -o out.jar (tested with v2.4 and 2.4.36).
  3. javap -v -p -c 'okio.AsyncTimeout$Watchdog' — observe the ldc <class>; monitorexit reload tails and the any ranges ending before them.
  4. Re-dex: d8 out.jar --release --min-api 21 --output redexed/.
  5. dexdump -d redexed/classes.dex — observe monitor-enter v1 but monitor-exit v0, and the <any> range no longer reaching the monitor-exit.
  6. Install on a device/emulator (API 21+) — the class fails ART verification with VerifyError.

Expected vs Actual

  • Expected: dex2jar reconstructs the synchronized block so that (a) a single reference is held and released across the critical section (or, if reloaded, the verifier can still prove balanced locking), and (b) the catch-all (any) handler covers every offset at which the monitor is held, up to and including the instruction immediately preceding each monitorexit. The result must round-trip back to DEX and pass ART verification.
  • Actual: dex2jar inserts a per-exit ldc <class> reload and emits an exception table whose catch-all range stops before that reload+exit tail. After re-dexing, this yields an enter/exit register mismatch and an uncovered monitor-held window, which ART rejects.

Notes / Scope

  • The desktop JVM does not surface this because, after dex2jar's output is run through a stackmap recompute (StackMapTable), HotSpot's verifier is more permissive about the typed-vs-catch-all distinction for non-throwing instructions. ART's structured-locking verifier is stricter, so the bug only manifests after re-dexing for Android.
  • Confirmed identical on dex-translator-v2.4 and dex-translator-2.4.36 — re-dexed run() instruction streams are byte-identical (only constant-pool indices differ). This is therefore not fixed by upgrading within the 2.4 line.
  • The downstream obfuscator and d8 were both ruled out as the cause (final DEX identical with/without the obfuscator; d8 faithfully encodes dex2jar's input).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions