Skip to content

Fix heap corruption on instructions that outgrow the parse tree - #289

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

Fix heap corruption on instructions that outgrow the parse tree#289
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 corrupts the heap on ordinary encodings. 0x10000000 is PowerPC 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

Six 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. The write happens inside a translation that then succeeds, so the death has no relation in time or place to the instruction that caused it.

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, 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

lookupswitch still does not decode; refusing it is containment, not support.

These sources are vendored from Ghidra 12.1.3 unchanged, so every released Ghidra carries the same defect. Upstream master rewrote this region in 5328fa2c6dbf (GP-6554) with equivalent bounds, which pypcode has not picked up.

Testing

ParseTreeTests decodes each encoding in a child interpreter, because master returns from translate before dying and the process doing it cannot observe the damage; test_context_is_reusable_after_a_refusal covers that a refused instruction leaves the cached ParserContext usable. Against a baseline build, -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.

Growing ParserWalker also moves 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 it has to be judged on its own tree rather than 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 54d3ad52d932d7a7cb4e7287778d47a0cdd860a4 against baseline 559aacdc9d363fd19477d9daa40721279cd99248.

  • 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

Reproducers, one instruction per process, Context.translate then Context.disassemble, five runs each and identical every run. Register lists are abbreviated in the last column.

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

Which glibc check fires, and whether the process aborts or faults, depends on heap layout and varies between builds; the PowerPC and ARM deaths happen after translate has already returned correct p-code.

Tree sizes, from a build carrying a counter in allocateOperand, against the allotment of 75 nodes and 32 breadcrumb entries the baseline hands out:

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
  • 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. angr already maps pypcode.BadDataError to Ijk_NoDecode. 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

Caveats:

  • JVM lookupswitch still does not decode. It raises now instead of writing past the node array, which is containment rather than support
  • 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
  • The sparc:BE:32:default and sparc:BE:64:default baseline deaths in the sweep above are a different defect — those trees never exceed 11 nodes, and it is the delay-slot UnimplError use-after-free that Fix segfault when a delay-slot instruction has no p-code #288 fixes. This change grows ParserWalker, which moves the stack enough that Fix segfault when a delay-slot instruction has no p-code #288's reproducers stop faulting on head; it does not repair them, and Fix segfault when a delay-slot instruction has no p-code #288 is still the fix
  • The counter-build measurements and the .sla and angr checks were taken at 478e6635c8b1b11f7a5b182268cf677bc6cb1532; the only change since then in built code is comment text, and everything else above was rerun at the head named here

@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 86.82%. Comparing base (559aacd) to head (54d3ad5).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #289   +/-   ##
=======================================
  Coverage   86.82%   86.82%           
=======================================
  Files           5        5           
  Lines         516      516           
  Branches       82       82           
=======================================
  Hits          448      448           
  Misses         26       26           
  Partials       42       42           

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

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. Translation returned normally with correct p-code and the
process died later, in an unrelated malloc or free, with a glibc abort or a
segfault.

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. NDS32 register-list instructions reach 120 nodes.

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, 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.

Over 20,000 random inputs against each of the 187 shipped languages, no
instruction outside JVM reaches either limit, and the largest tree any of them
builds is 120 nodes at 36 levels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@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, found while building both.

This branch makes a crash in MIPS:LE:32:default disappear that it does not fix. 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.

Nothing here is wrong — this branch does what it says, and it fixes JVM, NDS32 and the PowerPC case in #292, none of which #288 touches. It is only that the two overlap in a way that could hide a regression: with this merged, a delay-slot use-after-return will often not fault, 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, from a sweep that has since completed — and it cuts both ways, so both halves are here.

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.

The tempering half: 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 measures the fuzz corpus, not 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. It does mean the 24% figure should not be read as real-world frequency, which is why I am stating the composition rather than only the count.

One interaction worth knowing, from building both branches separately: this change makes #288's symptom disappear without fixing it. Detail is in a separate comment there and on #288; the short version is that 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. Under ASan the stack-use-after-return is still reported on this tree, identically to master.

@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. Every case runs in a child interpreter, because master returns from translate and dies afterwards, which the translating process cannot observe. Workspace paths are shortened to .../wt; nothing else is edited.

Before — five encodings return correct p-code and then abort in an unrelated allocator call, 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

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