Skip to content

Fix crashes on PowerPC, ARM, NDS32 and JVM instructions that outgrow the parse tree - #289

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-pypcode-operand-overflow
Open

zardus wants to merge 1 commit into
masterfrom
feature/fix-pypcode-operand-overflow

Conversation

@zardus

@zardus zardus commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Context.translate writes past fixed-size parse-tree arrays on ordinary encodings. On PowerPC 0x10000000, vaddubm v0,v0,v0, translation returns correct p-code and the process dies afterwards in an unrelated allocator call:

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000000'), max_instructions=1)
  decoded      vaddubm v0,v0,v0  (17 p-code ops)
  returning from the interpreter
  corrupted double-linked list
  child died on signal 6 (SIGABRT), subprocess returncode -6

Five more encodings across three shipped languages do the same — 0x10000040 and 0x10000440 (vadduhm, vsubuhm), ARM 14 0a 90 ec and 1f 0a 90 ec (vldmia with 20 and with 31 registers), NDS32 3b c5 37 45 (lmwa.bim) — dying as SIGABRT or SIGSEGV depending on heap layout. In angr, CFGFast dies with SIGSEGV on 40 JVM objects from megabench, each at a tableswitch or lookupswitch. They are generated 32- to 128-byte sequences, not class files, and are not public (SHA-256s in the validation record). An ordinary switch fails the same way: on master, a tableswitch with 9 or more cases, or a lookupswitch with 6 or more pairs (or none), kills the process.

Root cause

Every cached parse tree gets a fixed allotment, 75 nodes of 20 operands each in pypcode/sleigh/sleigh.cc, and a fixed path from the root in pypcode/sleigh/context.hh:

    pos->initialize(75,20,constspace);
  int4 breadcrumb[32];	// Path of operands from root

ParserContext::allocateOperand indexes all three and checks none of them:

  ConstructState *opstate = &state[alloc++];
  ...
  walker.point->resolve[i] = opstate;
  walker.breadcrumb[walker.depth++] += 1;

Measured on an instrumented build, one instruction per process:

Encoding Operands Nodes Breadcrumb entries
10 00 00 00 vaddubm 24 60 3
10 00 00 40 vadduhm 27 34 3
14 0a 90 ec vldmia, 20 registers 5 76 25
1f 0a 90 ec vldmia, 31 registers 5 109 36
3b c5 37 45 lmwa.bim 8 76 22

AltiVec arithmetic names one operand per vector lane, so vaddubm runs off the 20-entry operand array. A register list is one Constructor per register, so vldmia and lmwa.bim run off the 75-node array, and at 31 registers off the 32-entry breadcrumb trail as well. A JVM lookupswitch (ab) or tableswitch (aa) recurses once per table entry with the count read from the instruction stream, so its tree has no bound at all.

Fix

Size a node's operand array to the Constructor attached to it, in ParserWalkerChange::setConstructor; raise the node allotment to 512 and the breadcrumb trail to 128, several times the largest tree measured above; and have allocateOperand raise BadDataError rather than overrun either. That is already how translate and disassemble report undecodable input (pypcode/pypcode_native.cpp:309), and angr maps it to Ijk_NoDecode. All seven encodings then decode or refuse cleanly, the JVM one as:

  decoded      BadDataError: Instruction parse tree is too large
  returning from the interpreter
  child exited 0

JVM switches still do not decode; refusing them is containment, not support.

This code is vendored unchanged from Ghidra 12.1.4, which has the same defect. Upstream master reworked the allocation in 5328fa2c6dbf (GP-6554), which 12.1.4 does not contain: it grows the node array on demand but keeps the 20-operand and 32-level limits, now checked and raised as LowlevelError, so it refuses vaddubm, vadduhm, vsubuhm and the 31-register vldmia instead of decoding them.

Testing

ParseTreeTests decodes each encoding in a child interpreter, because on master the overrun raises nothing and the process doing it cannot observe the damage; test_context_is_reusable_after_a_refusal checks that a refused instruction leaves the cached ParserContext usable. The class is skipped under Pyodide, which master's CI tests since 22fe108 and which cannot start a child interpreter. Against a build of master, -k ParseTree over this branch's tests reports 8 failed, 2 passed, every failure a child killed by a signal (assert -11 == 0, assert -6 == 0); on this branch, 4 passed and 6 subtests passed.

On the 40 JVM objects, the sweep's per-object CFGFast completes on this branch where master dies, in 3 of 3 runs each, and the 3284 other p-code objects in the same sweep keep identical status, function, block and edge counts.

Growing ParserWalker also resizes every stack frame holding a walker, which makes a separate delay-slot use-after-return in sparc and MIPS stop faulting without repairing it. pypcode 288 is that fix and has to be judged on its own tree, not on top of this one.

Validation: #289 (comment)

session: sharpen

@zardus

zardus commented Aug 9, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 327cfbae75d237c47f1769b9e8e295024e81dc73 against baseline 91fe7c2b49860845d38355084bd24d9895e73ac4.

This head is the change rebased onto master at Ghidra 12.1.4, plus a skip of ParseTreeTests under Pyodide: since 22fe108, master runs the whole suite in a Pyodide CI job, and Pyodide cannot start the child interpreter those tests use. context.cc and context.hh are byte-identical to the previously validated head 54d3ad52d932d7a7cb4e7287778d47a0cdd860a4; sleigh.cc and tests/test_pypcode.py differ from it where master changed the surrounding lines, and the test file also by the skip. The dataset, reproducer, limits and decoding measurements ran on a build of 8e50d7fc3d9bd73b75e1ce2f6f5f000612959a43, which differs from this head only by the skip. The package is built without tests/, so both heads build the same env store path and the same pypcode_native (sha256 below). Base and head were built the same way and differ only in pypcode.

  • Dataset: 40 megabench JVM objects (generated 32- to 128-byte sequences, not class files) that the cfg sweep epoch 2026-10-09-cfg-megabench-r040044 recorded as crash — SIGSEGV on baseline in 3 of 3 runs, CFGFast completes on head in 3 of 3 runs. The baseline crash is at a tableswitch in 25 and a lookupswitch in 15. The objects are unavailable; their SHA-256s and results are below
  • Regression: the other 3284 objects of that sweep that use the p-code lifter — status, function, block and edge counts identical on all 3284 (ok 2827, no-functions 393, load-error 64 on both sides); 0 worse
  • Regression test: pytest --import-mode=append -q -p no:cacheprovider tests/test_pypcode.py -k ParseTree — baseline 8 failed, 2 passed, every failure a child killed by a signal (assert -11 == 0, assert -6 == 0); head 4 passed, 41 deselected, 6 subtests passed
  • Full suite on head: 50 passed, 200 subtests passed; the wheel job's python -m unittest discover -s tests reports Ran 50 tests and OK; tests/test_pypcode.py on baseline: 8 failed, 43 passed, 194 subtests passed
  • Pyodide skip: with sys.platform set to emscripten, all 4 ParseTreeTests are skipped and none error
  • Lint/type at this head: pre-commit run --from-ref origin/master --to-ref HEAD — every hook passes, including black, ruff and mypy at master's updated pins and the env's pylint
  • Reproducers: python -P repro.py --side SIDE --runs 5 — the seven encodings in the table below, five fresh processes each, identical in every run
  • Limits on head: python -P langs.py limits --n 20000 --seed j905 — 20,000 random 16-byte inputs for each of the 194 languages pypcode enumerates at this base; only JVM:BE:32:default reaches a limit, with 155 inputs refused as too large; no input in any language reaches the depth limit
  • Decoding unchanged: python -P langs.py hash --n 400 --seed j905 — 400 random 16-byte inputs per language, each side run twice and stable; all 190 languages that complete on baseline hash identically on head. Baseline dies in the other 4: ARM:LEBE:32:v7LEInstruction (vstmialt with 29 registers) and ARM:LEBE:32:v8LEInstruction (vpush with 26) are register lists this change bounds, JVM:BE:32:default is a tableswitch, and sparc:BE:64:default is the annulled delay-slot branch fbl,a from Fix segfault when a delay-slot instruction has no p-code #288's class. Up to each baseline death the head's hashes match
  • Crafted switches: on baseline, a tableswitch with 9 or more cases (low 0 with high 8, 16 or 100; also low 1000 with high 1008 and low -5 with high 3) and a lookupswitch with 0, 7, 8, 16 or 100 pairs die with SIGSEGV, and one with 6 pairs raises BadDataError and then aborts with SIGABRT; a tableswitch with 8 or fewer cases and a lookupswitch with 1 or 5 pairs are refused. The head refuses every case with BadDataError
Language Bytes Baseline Head
PowerPC:BE:32:default 10 00 00 00 SIGABRT, double free or corruption (!prev) vaddubm v0,v0,v0
PowerPC:BE:32:default 10 00 00 40 SIGSEGV vadduhm v0,v0,v0
PowerPC:BE:32:default 10 00 04 40 SIGSEGV vsubuhm v0,v0,v0
NDS32:LE:32:default 3b c5 37 45 SIGABRT, free(): invalid size lmwa.bim fp, [s4], s7, 0xd
ARM:LE:32:v7 14 0a 90 ec SIGABRT, free(): invalid size vldmia r0,{s0,s1,...,s19}
ARM:LE:32:v7 1f 0a 90 ec SIGSEGV vldmia r0,{s0,s1,...,s30}
JVM:BE:32:default ab SIGSEGV BadDataError: Instruction parse tree is too large

Register lists are abbreviated in the last column. Which glibc check fires, and whether the process aborts or faults, depends on heap layout and varies between builds.

Caveats:

How these were run

The dataset rows come from sweep_side.py, which runs each object through the sweep's own per-object runner, unit.sweep_object: one forked child per object, the catalog's load recipe (blob backend, ArchPcode from the declared language, base and entry 0x1000), CFGFast(normalize=True, data_references=False, resolve_indirect_jumps=True), a 900 s CPU budget and a 2048 MB RSS cap. Each side ran under its own env, four shards (K = 0 to 3):

  • python -P sweep_side.py --side base --shard K/4 --out sweep-base.K.jsonl
  • python -P sweep_side.py --side head --shard K/4 --out sweep-head.K.jsonl

The 40 objects were run twice more per side with --only. python -P switch_check.py SIDE translates the one crashing instruction of each object in a fresh interpreter (baseline SIGSEGV on all 40, head BadDataError, opcode byte aa or ab as listed) and the crafted switches above with low 0, followed by zero bytes; the other two tableswitch ranges were run the same way with one_translate.py. repro.py runs translate and then disassemble on one instruction in a fresh interpreter. langs.py uses one process per language, a fixed seed, and hashes instruction length, mnemonic, operand text and every p-code op, with the LOAD/STORE space operand resolved to its space name.

pypcode_native sha256: baseline b82fe757a7dd62abc38f5d28efa2cd3fe71450d8b0ece24cf1762e701070da70, head 319d68f0414831d17b04badbcb55e747de9e3eff544e4fc3f79d13159738b798.

The 40 objects
SHA-256 Crashing instruction Baseline Head
080f88a7b64249f94e5abab2eff47c3c038c27233e3a1c8ab7e547d75c902b49 0x1018 lookupswitch SIGSEGV ok, 3 functions, 3 blocks
09a2222bc6c45a7a9127d10285552fd11521f918aa3c69572baf7387862cbc57 0x102f tableswitch SIGSEGV ok, 3 functions, 4 blocks
10141601030e68f9cce05ad702570ed356ef1c16fe13c93c0743744e07f146d2 0x1028 lookupswitch SIGSEGV ok, 7 functions, 9 blocks
119c253586e562d61b641d7913a4bc354723513497e80c23277c59434b2cd8e8 0x1016 tableswitch SIGSEGV ok, 1 functions, 1 blocks
14d5f41e5d9df892bc81c7bf67d6fd5de0347c21d1a0262a84bcaf8459bdc0c8 0x101d lookupswitch SIGSEGV ok, 3 functions, 3 blocks
1f24d2a2fce8e3bbd616866666b43ccc0769d407c3338eb271e95d09f450a615 0x1009 lookupswitch SIGSEGV ok, 5 functions, 5 blocks
24395d229e2121e53cd07151cd9430e8816bb3bb56bda48bd0775b21728f31a5 0x102a tableswitch SIGSEGV ok, 8 functions, 11 blocks
32688c4e48109b39b974b88bd84ca4d2d90bb67c91f0c7e2f54126a5b05a1083 0x1002 tableswitch SIGSEGV ok, 1 functions, 1 blocks
427175271781a38aace0ee1996810bdcb6baccb8ee26b4d002c7aaff5a5300de 0x100c lookupswitch SIGSEGV ok, 3 functions, 3 blocks
48b0c3011c2a5dcf987818fa61ad335d5314667bc10f03b3320a617db4a94417 0x105f lookupswitch SIGSEGV ok, 6 functions, 9 blocks
54648d1618e247d461e1b744bcc8923c41bc1062ff1301ff8f9f2a28e9c04b03 0x105d lookupswitch SIGSEGV ok, 5 functions, 6 blocks
54fe9364d7f8a48713e554a200fd6c45af5f6f3c3d1da7c07fc35cd1849456fc 0x100d lookupswitch SIGSEGV ok, 4 functions, 4 blocks
5b67d4d63636d51c6bd5bf5fd74c17ca3a8d0d17ad7e90667f3f0d3ca3014d0e 0x1047 tableswitch SIGSEGV ok, 10 functions, 10 blocks
5f6350d7d823c5c209f6a7f5b2ba35d3fbc18902411b10331d3d611253f52454 0x1003 tableswitch SIGSEGV ok, 4 functions, 4 blocks
6088211345c97af8eb22238930299f31a7e0a1d4a63c9420b769e1fb073a76b7 0x100e tableswitch SIGSEGV ok, 2 functions, 2 blocks
667a17675b785539be033b2da99245c49e07afb6ae00a5304c8d538f1e9005a5 0x1010 tableswitch SIGSEGV ok, 2 functions, 3 blocks
670e82c3287a75a0c662c7e7037570d3fb1469662ff6326987d2ed473b7c801a 0x106a tableswitch SIGSEGV ok, 3 functions, 4 blocks
702c2e7a717b4bab30863ba17ab6aded5d7f55bbeb7cf5f2a89ad056eb9f7dcb 0x1032 tableswitch SIGSEGV ok, 7 functions, 8 blocks
7ce6e785b92a2d80a62b92f9749dd98a4828fec0f8d584f22114a1c856e68d48 0x101e tableswitch SIGSEGV ok, 4 functions, 4 blocks
81af7fccebc63ea2d1a5f6f92a8a959e4a135aefce577981229a8e6542ebfbd4 0x1026 tableswitch SIGSEGV ok, 5 functions, 6 blocks
823d6756b316f0562c47e6bb37518d12467fcdd8a8f9bcb25cafd2ea07f5a9c2 0x101c tableswitch SIGSEGV ok, 6 functions, 8 blocks
8509adc56d0e8f18b933680829c799009113e6a61d3edb370c3902406b371f45 0x1012 lookupswitch SIGSEGV ok, 1 functions, 1 blocks
86b8a59d8ae67001d010d0edd78aba89dc99527f8207a448cc8425782e37776e 0x105a lookupswitch SIGSEGV ok, 3 functions, 3 blocks
8effcbd80320dbc7e253accc39244b3c9dbf1c5101a15bb1b04cc7cfcdd37b80 0x1010 tableswitch SIGSEGV ok, 2 functions, 2 blocks
92c3a3d1524cd248913f8620f4af39b4293e298271836ad79749b93152e28f0d 0x101b lookupswitch SIGSEGV ok, 6 functions, 6 blocks
9755051a78e8618638ddba3699dde58979cf1a00747eb8beb475ba95bc832c16 0x100d tableswitch SIGSEGV ok, 3 functions, 5 blocks
9fac425b48cc026e8c9d616538e01f16bc54770d18effafa6fcc05e2da8fd976 0x1012 tableswitch SIGSEGV ok, 3 functions, 3 blocks
b40ea7574798fafca814940bbdacda1fdfefbe5ef31de8c7f1a35861e2ace955 0x1007 tableswitch SIGSEGV ok, 7 functions, 9 blocks
b6d9a0be3e0d2357da892d9db816bef01a5fd19174dd023d118320a6d8c0b414 0x1042 tableswitch SIGSEGV ok, 6 functions, 6 blocks
b8428c8c9447dc41317ca95631d2f5c924512640c74a74c6259593057521b8dc 0x1002 tableswitch SIGSEGV ok, 5 functions, 5 blocks
bad6663b0cce5cbce9b4170a2283b5160d9c82d40549f937620e792c6f5827cc 0x104f tableswitch SIGSEGV ok, 2 functions, 2 blocks
c00b2bea602f075ec6d4c28932fa1b721efe2b0e6ca98831ee47d0b822e93d2c 0x1018 tableswitch SIGSEGV ok, 4 functions, 4 blocks
c3f8c346c804bd1ddba70d4cc10653d4e780b1c729f5cd2e2d2b389eb2d4322b 0x101d lookupswitch SIGSEGV ok, 1 functions, 1 blocks
cfc2fa2ee894f761a940c18b9dfa4ade621b4018f67dc3ed601ec2e4684796e1 0x1016 tableswitch SIGSEGV ok, 3 functions, 5 blocks
d46e9e49c179ff9ea4d83efa6f887386a731e775e3c3968d84ef3d266062f082 0x1057 lookupswitch SIGSEGV ok, 5 functions, 5 blocks
da031cfd926cc2ecea89cd1aa070933a56186b3ffddd6523f1980fea40baa465 0x102e tableswitch SIGSEGV ok, 2 functions, 2 blocks
e606b86aecfcb5ecbb263905d28c6502fa36944ad6c47b64cf8428c3fe45aed2 0x103b tableswitch SIGSEGV ok, 4 functions, 5 blocks
f5e93a29527fbdfb28202364c6a4fde44a3a915ba5708e794f06d3cd3695a78a 0x101e tableswitch SIGSEGV ok, 1 functions, 1 blocks
fa10acfdbfccb2cdb760d171e353557937f64a19fcfcf39f1e7a1ba85f6e08ff 0x1055 lookupswitch SIGSEGV ok, 8 functions, 12 blocks
fead0c91f8f73d04364a56f05b909a3ce221ddf4c68adf639b8d230f166f52a4 0x102c lookupswitch SIGSEGV ok, 5 functions, 9 blocks

Earlier measurements, at baseline 559aacdc9d363fd19477d9daa40721279cd99248 (Ghidra 12.1.3) and head 54d3ad52d932d7a7cb4e7287778d47a0cdd860a4 unless a line says otherwise. The 12.1.4 update does not touch the constructors of the seven encodings.

  • Regression: this branch's tests/test_pypcode.py against a baseline build — Ran 45 tests ... FAILED (failures=8), every failure in ParseTreeTests and the other 41 tests passing; on head pytest tests/test_pypcode.py -k ParseTree is 4 passed and 6 subtests passed
  • Full suite: pytest — 50 passed, 193 subtests passed, 0 skipped
  • Lint/type: pre-commit run --all-files — black, ruff, mypy, pylint and clang-format all pass and no hook rewrote a file. The repository config excludes pypcode/sleigh, so the vendored sources keep upstream formatting
  • Workspace gate: pypcode built and tested from this branch; archinfo, pyvex, cle, claripy, angr and angr-management are wheel-installed and their suites skipped
  • Build workflow jobs run locally: python -m build --sdist, the wheel job's python -m unittest discover -s tests, and make html coverage

Tree sizes from a build with a counter in allocateOperand, against the baseline's allotment of 75 nodes and 32 breadcrumb entries:

Instruction Nodes Levels
vaddubm 60 3
vadduhm, vsubuhm 34 3
vldmia r0,{s0-s19} 76 25
vldmia r0,{s0-s30} 109 36
lmwa.bim fp,[s4],s7,0xd 76 22

The operand counts come from the shipped specs: altivec.sinc gives vaddubm_part1 and vaddubm_part2 24 operands each and vadduhm and vsubuhm 27, against an allotment of 20.

  • Limits: 20,000 random 16-byte inputs against each of the 187 shipped languages, one process per language, on a build with the same counter — the largest tree outside JVM is 120 nodes at 36 levels (NDS32:BE:32:default), and JVM:BE:32:default is the only language that ever reaches a limit, refusing 157 of its 20,000 inputs
  • Decoding unchanged: 400 random 16-byte inputs per language, one process per language, hashing mnemonic, operand text, instruction length and every p-code op — all 181 languages that survive both builds hash identically and none differ. The LOAD/STORE space operand holds an AddrSpace pointer, so it is resolved to a space name before hashing; without that the hash moves with ASLR on both builds alike
  • The sparc:BE:32:default and sparc:BE:64:default baseline deaths in that sweep are a different defect — those trees never exceed 11 nodes, and it is the delay-slot UnimplError use-after-return that Fix segfault when a delay-slot instruction has no p-code #288 fixes
  • Both bounds are load-bearing. At the shipped node cap the JVM recursion hits the node bound first; rebuilt with only the node cap raised to 100,000, the same input reports Instruction parse tree is too deep instead, so the breadcrumb trail needs its own check
  • Compiler unaffected: the 149 .sla files the build regenerates are byte-identical to baseline
  • Throughput: x86:LE:64:default, 400 KiB of random bytes decoded at every byte offset, 358,037 successful decodes on both builds, best of 7 — 0.516 s baseline, 0.517 s head
  • Memory: 20 Context objects per language, resident set divided by 20 — x86:LE:64:default 26,008 to 26,247 KiB, MIPS:BE:32:default 9,001 to 9,240 KiB, sparc:BE:32:default 1,788 to 2,023 KiB, so roughly 235 KiB more per Context
  • Error path: translate and disassemble already suppress a BadDataError raised after the first instruction, so a refused instruction ends the block and the instructions before it are kept. Refusing abandons a half-built tree in a cached ParserContext, and test_context_is_reusable_after_a_refusal covers that the Context keeps working: neighbouring addresses in the same cache slot still decode and asking again at the refused address raises
  • angr: CFGFast over blobs of the PowerPC, NDS32 and JVM reproducers with angr 9.3.3.dev0 — baseline dies on all three, head completes all three. The ARM reproducer cannot be checked this way: CFGFast on an ArchPcode ARM blob raises AttributeError: 'CFGArchOptions' object has no attribute 'switch_mode_on_nodecode' on both builds, which is an unrelated angr defect
  • The counter-build measurements and the .sla and angr checks were taken at 478e6635c8b1b11f7a5b182268cf677bc6cb1532; the only change between it and 54d3ad52 in built code is comment text

@codecov

codecov Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 83.65%. Comparing base (91fe7c2) to head (327cfba).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #289   +/-   ##
=======================================
  Coverage   83.65%   83.65%           
=======================================
  Files           5        5           
  Lines         514      514           
  Branches       98       98           
=======================================
  Hits          430      430           
  Misses         26       26           
  Partials       58       58           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@zardus

zardus commented Aug 25, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

A note on an interaction with #288.

This branch makes a crash in MIPS:LE:32:default disappear without fixing it. That case is a delay-slot use-after-return — #288's defect — and on a -fsanitize=address build of this branch the fault is still reported, at sleigh.cc:388, on SleighBuilder::delaySlot's newwalker, identically to master. The only difference is the stack slot's size, 160 bytes on master against 544 here, because growing ParserWalker::breadcrumb from int4[32] to int4[128] resizes every frame holding a walker and changes what the dangling pointer reads. On the #288 tree ASan reports nothing.

This branch still fixes JVM, NDS32 and the PowerPC case in #292, none of which #288 touches. But with it merged, a delay-slot use-after-return will often not fault, which could hide a regression, so #288 should be evaluated on its own tree rather than on top of this one.

@zardus

zardus commented Aug 25, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Corpus frequency for this defect, from a sweep that has since completed.

Of 746,966 objects analysed, 479 died on a signal. This defect accounts for 115 of them, 24% of every native crash and the second largest cause. It also covers the PowerPC vaddubm heap corruption in #292, which is 6 more.

All 115 are 32-to-128-byte synthetic blobs from a generated p-code collection. Not one is a real-world binary. So the rank and the 24% measure the fuzz corpus, not real-world exposure: by real-world objects the same run puts a delay-slot use-after-return (#288) at 220 and an out-of-memory family at 40 ahead of it.

That does not make it less of a defect. pypcode.Context("JVM:BE:32:default").translate(b"\xab") is one byte, and a parse-tree overrun writing past a 75-element vector is memory corruption whatever reaches it.

One interaction worth knowing: this change makes #288's symptom disappear without fixing it. Growing ParserWalker::breadcrumb from int4[32] to int4[128] resizes every stack frame holding a walker, so a dangling-pointer read stops landing anywhere fatal. Detail is in a separate comment here and on #288.

@zardus

zardus commented Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Context.translate on the seven encodings whose Constructor tree outgrows ParserContext's arrays — PowerPC 0x10000000, 0x10000040 and 0x10000440, ARM 14 0a 90 ec and 1f 0a 90 ec, NDS32 3b c5 37 45, and JVM ab — before and after this change. Each case runs in a child interpreter, so the capture shows how that process ended. Workspace paths are shortened to .../wt; nothing else is edited.

Before — three encodings return correct p-code and then abort in an unrelated allocator call, two abort inside translate, and two fault outright:

pypcode master, 559aacd
pypcode: .../wt/pypcode-base/pypcode/__init__.py
native : .../wt/pypcode-base/pypcode/pypcode_native.cpython-312-x86_64-linux-gnu.so

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000000'), max_instructions=1)
  # 0x10000000 vaddubm: 24 operands against an allotment of 20
  decoded      vaddubm v0,v0,v0  (17 p-code ops)
  returning from the interpreter
  corrupted double-linked list
  child died on signal 6 (SIGABRT), subprocess returncode -6

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000040'), max_instructions=1)
  # 0x10000040 vadduhm: 27 operands
  malloc(): smallbin double linked list corrupted
  child died on signal 6 (SIGABRT), subprocess returncode -6

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000440'), max_instructions=1)
  # 0x10000440 vsubuhm: 27 operands
  malloc(): smallbin double linked list corrupted
  child died on signal 6 (SIGABRT), subprocess returncode -6

$ Context('ARM:LE:32:v7').translate(bytes.fromhex('140a90ec'), max_instructions=1)
  # 0xec900a14 vldmia r0,{s0-s19}: 76 nodes against an allotment of 75
  decoded      vldmia r0,{s0,s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11,s12,s13,s14,s15,s16,s17,s18,s19}  (42 p-code ops)
  returning from the interpreter
  double free or corruption (!prev)
  child died on signal 6 (SIGABRT), subprocess returncode -6

$ Context('ARM:LE:32:v7').translate(bytes.fromhex('1f0a90ec'), max_instructions=1)
  # 0xec900a1f vldmia r0,{s0-s30}: 109 nodes, 36 levels deep
  child died on signal 11 (SIGSEGV), subprocess returncode -11

$ Context('NDS32:LE:32:default').translate(bytes.fromhex('3bc53745'), max_instructions=1)
  # 0x4537c53b lmwa.bim: 76 nodes
  decoded      lmwa.bim fp, [s4], s7, 0xd  (45 p-code ops)
  returning from the interpreter
  free(): invalid size
  child died on signal 6 (SIGABRT), subprocess returncode -6

$ Context('JVM:BE:32:default').translate(bytes.fromhex('ab'), max_instructions=1)
  # 0xab lookupswitch: recurses once per table entry, unbounded
  child died on signal 11 (SIGSEGV), subprocess returncode -11

After — six decode and exit cleanly, and the unbounded JVM lookupswitch is refused with BadDataError instead of writing past the node array:

with this change, 54d3ad5
pypcode: .../wt/pypcode-289-head/pypcode/__init__.py
native : .../wt/pypcode-289-head/pypcode/pypcode_native.cpython-312-x86_64-linux-gnu.so

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000000'), max_instructions=1)
  # 0x10000000 vaddubm: 24 operands against an allotment of 20
  decoded      vaddubm v0,v0,v0  (17 p-code ops)
  returning from the interpreter
  child exited 0

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000040'), max_instructions=1)
  # 0x10000040 vadduhm: 27 operands
  decoded      vadduhm v0,v0,v0  (9 p-code ops)
  returning from the interpreter
  child exited 0

$ Context('PowerPC:BE:32:default').translate(bytes.fromhex('10000440'), max_instructions=1)
  # 0x10000440 vsubuhm: 27 operands
  decoded      vsubuhm v0,v0,v0  (9 p-code ops)
  returning from the interpreter
  child exited 0

$ Context('ARM:LE:32:v7').translate(bytes.fromhex('140a90ec'), max_instructions=1)
  # 0xec900a14 vldmia r0,{s0-s19}: 76 nodes against an allotment of 75
  decoded      vldmia r0,{s0,s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11,s12,s13,s14,s15,s16,s17,s18,s19}  (42 p-code ops)
  returning from the interpreter
  child exited 0

$ Context('ARM:LE:32:v7').translate(bytes.fromhex('1f0a90ec'), max_instructions=1)
  # 0xec900a1f vldmia r0,{s0-s30}: 109 nodes, 36 levels deep
  decoded      vldmia r0,{s0,s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11,s12,s13,s14,s15,s16,s17,s18,s19,s20,s21,s22,s23,s24,s25,s26,s27,s28,s29,s30}  (64 p-code ops)
  returning from the interpreter
  child exited 0

$ Context('NDS32:LE:32:default').translate(bytes.fromhex('3bc53745'), max_instructions=1)
  # 0x4537c53b lmwa.bim: 76 nodes
  decoded      lmwa.bim fp, [s4], s7, 0xd  (45 p-code ops)
  returning from the interpreter
  child exited 0

$ Context('JVM:BE:32:default').translate(bytes.fromhex('ab'), max_instructions=1)
  # 0xab lookupswitch: recurses once per table entry, unbounded
  decoded      BadDataError: Instruction parse tree is too large
  returning from the interpreter
  child exited 0

ParserContext holds the parse tree of the instruction being decoded in three
fixed arrays: a node array and a per-node operand array, both sized once by
ParserContext::initialize, and the walker's breadcrumb trail. allocateOperand
indexed all three without checking any of them, so an instruction whose tree
did not fit wrote past the end of two heap blocks and into the walker's own
context member. Depending on the instruction and the heap layout, the process
then died with a segfault or a glibc heap-check abort, either during
translation or after translation had returned.

Ordinary encodings reach every one of the three. PowerPC AltiVec spells
arithmetic out one operand per vector lane, so vaddubm declares 24 operands and
vadduhm and vsubuhm declare 27, against an allotment of 20. A register list
takes one Constructor per register, so ARM vldmia r0,{s0-s19} needs 76 nodes
against 75, and vldmia r0,{s0-s30} needs 109 and nests 36 deep against a
32-entry breadcrumb trail.

Give the node array room for 512 nodes and the breadcrumb trail room for 128,
have setConstructor size a node's operand array to the Constructor it is
attached to, and have allocateOperand raise BadDataError rather than allocate a
node the arrays cannot hold. BadDataError is already how oneInstruction reports
undecodable input, so a tree that genuinely has no bound of its own -- a JVM
lookupswitch or tableswitch, which nests one level per table entry and takes
the entry count from the instruction stream -- now reaches the caller as a
catchable exception. Refusing abandons a half-built tree in a cached
ParserContext, so the tests cover that the Context stays usable afterwards.
The tests run each instruction in a child interpreter, so they are skipped
under Pyodide, which cannot start one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/fix-pypcode-operand-overflow branch from 54d3ad5 to 327cfba Compare October 23, 2026 19:22
@zardus zardus changed the title Fix heap corruption on instructions that outgrow the parse tree Fix crashes on PowerPC, ARM, NDS32 and JVM instructions that outgrow the parse tree Oct 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant