You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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 eachmonitorexit, 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:
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
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.
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 anysynchronized 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
Take any APK containing okio.AsyncTimeout (okio 2.x) — or any class with a synchronized block that has an early return inside the lock.
Extract classes.dex and run d2j-dex2jar.sh classes.dex -o out.jar (tested with v2.4 and 2.4.36).
javap -v -p -c 'okio.AsyncTimeout$Watchdog' — observe the ldc <class>; monitorexit reload tails and the any ranges ending before them.
dexdump -d redexed/classes.dex — observe monitor-enter v1 but monitor-exit v0, and the <any> range no longer reaching the monitor-exit.
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).
Summary
When
dex2jartranslates a method that contains a Kotlin/Javasynchronizedblock, the reconstructed.classdoes not preserve the structured-locking shape that the original DEX had. Specifically, dex2jar reloads the lock object (via a freshldc <class>) immediately before eachmonitorexit, and the generated exception table's catch-all (any) range ends before that reloadedmonitorexittail. The instructions sitting between the end of the catch-all range and themonitorexitare therefore covered only by a typed handler (InterruptedException), not by the catch-all.The intermediate
.classstill "works" on a desktop JVM after a verifier recompute, so the defect is easy to miss. However, when the.classis converted back to DEX (e.g.d8/r8in a re-obfuscation or re-packaging pipeline), the register allocator bindsmonitorenterandmonitorexitto 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 withjava.lang.VerifyError.This report uses
okio.AsyncTimeout$Watchdog.run()(Kotlin, a plainsynchronizedblock) as a minimal, real-world reproducer. The behavior was confirmed on two dex2jar builds (dex-translator-v2.4and the maintained forkdex-translator-2.4.36) — the final DEX is byte-identical between them, so this is an architectural translation issue, not a version regression.Environment
dex-translator-v2.4.jaranddex-translator-2.4.36.jar(maintained fork). Both reproduce identically.classes.dexcontainsokio.AsyncTimeout(okio 2.x, Kotlin).d83.3.20 andd88.x — both carry the defect (d8 is faithful, see below).dexdump(build-tools 28.0.3) andjavap -v.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 registerv1for the entire critical section, and everymonitor-exitreleases that samev1: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 themonitor-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 withldc <class>immediately before everymonitorexit, and the exception table'sanyranges stop short of that reload+exit tail:The two structural regressions
Reload-per-exit instead of a single held reference. The original DEX keeps the lock object in
v1and exits onv1. dex2jar discards that dataflow fact and re-materializes the class constant (ldc #6) before eachmonitorexit. This is the proximate cause of regression maven releases #2.Catch-all coverage gap. Because the
ldcwas inserted before themonitorexitbut theanyrange 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 anInterruptedExceptionhandler — not by the catch-all. If anything other thanInterruptedExceptionis thrown there (e.g. class-resolution failure on theldc, 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
.classto DEX withd8faithfully encodes both regressions. d8 is not at fault — it correctly register-allocates the reloaded constants, which is precisely what makes the breakage observable:Compared to the original DEX:
monitor-enter v1vsmonitor-exit v0. The original usedv1throughout.<any>range ended exactly at themonitor-exit; here it ends at0x0019while themonitor-exithas moved to0x001b, leaving theconst-class/monitor-exittail 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:
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 anysynchronizedblock with more than one exit edge (returninside the lock + the implicit exception path).okio.AsyncTimeout$Watchdogis just the first class to trip it; the pattern is generic to Kotlin/Javasynchronized.Reproduction Steps
okio.AsyncTimeout(okio 2.x) — or any class with asynchronizedblock that has an earlyreturninside the lock.classes.dexand rund2j-dex2jar.sh classes.dex -o out.jar(tested with v2.4 and 2.4.36).javap -v -p -c 'okio.AsyncTimeout$Watchdog'— observe theldc <class>; monitorexitreload tails and theanyranges ending before them.d8 out.jar --release --min-api 21 --output redexed/.dexdump -d redexed/classes.dex— observemonitor-enter v1butmonitor-exit v0, and the<any>range no longer reaching themonitor-exit.VerifyError.Expected vs Actual
synchronizedblock 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 eachmonitorexit. The result must round-trip back to DEX and pass ART verification.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
dex-translator-v2.4anddex-translator-2.4.36— re-dexedrun()instruction streams are byte-identical (only constant-pool indices differ). This is therefore not fixed by upgrading within the 2.4 line.d8were both ruled out as the cause (final DEX identical with/without the obfuscator; d8 faithfully encodes dex2jar's input).