Skip to content

Fix segfault when a delay-slot instruction has no p-code - #288

Open
zardus wants to merge 1 commit into
masterfrom
feature/fix-pypcode-delayslot-uaf
Open

Fix segfault when a delay-slot instruction has no p-code#288
zardus wants to merge 1 commit into
masterfrom
feature/fix-pypcode-delayslot-uaf

Conversation

@zardus

@zardus zardus commented Aug 9, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

Context.translate takes the whole process down with SIGSEGV when a delay-slot branch's delay slot has no p-code. Eight bytes reach it — 0x63748596, a sparc call, followed by 0xa7b8c9da:

$ Context('sparc:BE:32:default').translate(bytes.fromhex('63748596a7b8c9da'))
  disassembly  0x00000000: call     -0x722de9a8
  disassembly  0x00000004: impdef2
  child died on signal 11 (SIGSEGV), subprocess returncode -11

Toy:BE:32:default does the same on 0xf500 (callds) ahead of 0xa800, with or without a decodable instruction in front. Disassembling those bytes succeeds, and an instruction with no p-code is otherwise a catchable UnimplError, so nothing walking unknown bytes — CFG recovery over a blob, a language sweep — can survive this.

Root cause

SleighBuilder::delaySlot in pypcode/sleigh/sleigh.cc installs a walker living on its own stack frame and puts the previous one back only after the loop:

  ParserWalker *tmp = walker;
  uintb olduniqueoffset = uniqueoffset;
  ...
    ParserWalker newwalker( pos );
    walker = &newwalker;
    walker->baseState();
    build(walker->getConstructor()->getTempl(),-1); // Build the whole delay slot
  ...
  walker = tmp;			// Restore original context
  uniqueoffset = olduniqueoffset;

build throws UnimplError for a constructor with no p-code section (pypcode/sleigh/semantics.cc:929). The throw skips both assignments, so the builder still points at newwalker once that frame is gone — and Sleigh::oneInstruction describes the error through exactly that pointer:

  } catch(UnimplError &err) {
    ParserWalker *cur = builder.getCurrentWalker();
    cur->baseState();
    Constructor *ct = cur->getConstructor();

appendCrossBuild carries the same tail-only restore.

Fix

Both functions restore through a scope object, so no exit path can leave the builder holding a destroyed walker:

  ~WalkerScope(void) {
    curwalker = oldwalker;
    curuniqueoffset = olduniqueoffset;
  }

The failure then names the branch at its own address, which is what the error's instruction_length = fallOffset already describes — fallOffset spans the branch and its delay slot:

  translate    UnimplError: Instruction not implemented in pcode:
   0x00000000: call  -0x722de9a8

With a decodable instruction ahead of the branch, that instruction is kept and the block ends there: translate 1 p-code op(s), 1 instruction(s) kept.

appendCrossBuild gets the guard for the same invariant but has no reproducer here: Hexagon is the only shipped language using CROSSBUILD, and it has no delay slots.

Both functions are vendored from Ghidra unchanged, and Ghidra master at 382b26c9a508604d165ed9b20e8fb41c2a6edfc7 still carries the identical tail-only restore, so the defect is upstream too.

Testing

TranslateTests::test_delay_slot_unimpl_failure and test_partial_delay_slot_unimpl_failure cover the sparc and Toy encodings above, each alone and each behind a decodable instruction, asserting the reported address is the branch's own. Against a baseline build of pypcode/sleigh/sleigh.cc the run never reaches an assertion: pytest exits 139 with Fatal Python error: Segmentation fault in test_delay_slot_unimpl_failure. On this branch, 2 passed and 4 subtests passed.

Validation: #288 (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 2bbb34dc13c44db056ae0493b7a56f451058b030 against baseline 559aacdc9d363fd19477d9daa40721279cd99248.

  • Reproducer: python -c 'import pypcode; pypcode.Context("sparc:BE:32:default").translate(bytes.fromhex("63748596a7b8c9da"))' — SIGSEGV on baseline, UnimplError naming the call on head
  • Regression: python -m pytest tests/test_pypcode.py -k "delay_slot_unimpl or partial_delay_slot" — 2 passed on head; on a baseline build of pypcode/sleigh/sleigh.cc the process dies with SIGSEGV in test_delay_slot_unimpl_failure, exit 139
  • End to end: CFGFast over those eight bytes as a sparc:BE:32:default blob, angr 9.3.3.dev0 — SIGSEGV on baseline, completes on head
  • Focused: python -m pytest tests/ — 48 passed, 191 subtests passed
  • As the wheel job runs it: python -m unittest discover -s tests — Ran 48 tests, OK
  • Lint/type: pre-commit run --all-files — of 27 hooks, 25 passed and 2 skipped for having no files to check, none rewrote a file; black, ruff, mypy and pylint included
  • Docs: make -C docs html coverage — build succeeded, docstring coverage 100%
  • Differential: all 187 shipped languages, 64 pseudorandom 16-byte inputs each, one process per language — the 182 languages that survive both revisions produce identical p-code and identical exception text on all 11,648 results, with space-id operands normalized because they carry raw AddrSpace pointers that differ per build; sparc:BE:64:default segfaults on baseline and survives on head
  • The two builds differ only in pypcode/sleigh/sleigh.cc; the sleigh compiler, the .sla files and every other object are unchanged

Caveats, one line each:

  • appendCrossBuild is fixed for the same invariant but has no reproducer: inside a named p-code section every build() call checks its template for null first, so only a delay slot in a crossbuild target could raise UnimplError there, and Hexagon, the only shipped language using CROSSBUILD, has no delay slots.
  • The differential run also crashes on ARM, JVM and NDS32 from unrelated defects, identically on both revisions.
  • Measured on Linux x86_64 with GCC 15.2.0 and Python 3.12.13; Windows and macOS are covered only by this PR's CI.

@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 (2bbb34d).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #288   +/-   ##
=======================================
  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.

SleighBuilder::delaySlot points the builder at a ParserWalker on its own
stack frame and puts the previous one back only on the normal exit path.
When the delay-slot instruction has no p-code section, PcodeBuilder::build
throws UnimplError, the walker is destroyed with the frame, and the
builder is left holding that address. Sleigh::oneInstruction catches the
exception and describes it through exactly that pointer, reading a
ParserContext out of stack the handler has already reused, so
Context.translate segfaults on any delay-slot branch followed by an
instruction with no semantics. Eight bytes of SPARC are enough:

    Context("sparc:BE:32:default").translate(bytes.fromhex("63748596a7b8c9da"))

SleighBuilder::appendCrossBuild saves and restores the walker the same
unguarded way around a build() that can throw.

Restore both through a scope object so an exception leaving either method
cannot outlive the walker it installed. The reported instruction is now
the branch rather than its delay slot, which matches the instruction
length UnimplError already carries.

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

Two things worth knowing before this is judged, both from building the trees rather than reading them.

This has a real-world instance now, not only the synthetic one. MIPS:LE:32:default segfaults on twelve bytes taken from a corpus object — lwl k0, 0x4255(k1) / jalx 0x8965abc / plu.ps f23, f31, f21, where jalx is a delay-slot branch and plu.ps (MIPS-3D paired-single) has no p-code section. This branch fixes it; the block truncates at jalx and returns 15 ops, keeping the earlier lwl, exactly as test_partial_delay_slot_unimpl_failure describes. Big-endian MIPS reads the same bytes as bnel with sw in the delay slot, sw has p-code, nothing throws, and it is clean on every tree — which is the whole of the byte-order asymmetry.

#289 makes that symptom disappear without fixing the defect, so this must not be judged on top of it. Built separately:

                          master    #288      #289      both
MIPS:LE:32:default        SIGSEGV   clean     clean     clean
SPARC (#288's repro)      SIGSEGV   Unimpl    Unimpl    Unimpl
JVM lookupswitch          SIGSEGV   SIGSEGV   BadData   BadData
NDS32                     SIGSEGV   SIGSEGV   clean     clean

On a -fsanitize=address build the reason is explicit. Master reports

stack-use-after-return ... READ of size 8
  [192, 352) 'newwalker' (line 388) <== Memory access inside this variable

and the #289 tree reports the same object at the same line, differing only in the frame slot's size, 160 bytes against 544 — because #289 grows ParserWalker::breadcrumb from int4[32] to int4[128], which resizes every frame holding a walker and moves what the dangling pointer lands on. The #288 tree produces no ASan report at all.

So if #289 lands first, a test of this case will pass whether or not this branch is applied, and the use-after-return will still be there.

@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Context.translate on four delay-slot branches whose delay slot has no p-code — sparc 0x63748596 (call) and Toy 0xf500 (callds), each on its own and each behind one decodable instruction — before and after this change. Every case runs in a child interpreter, because master dies on a signal the translating process cannot observe. Workspace paths are shortened to .../wt; nothing else is edited.

Before — each case takes the whole process down with SIGSEGV, after disassembly of the same bytes has already succeeded:

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('sparc:BE:32:default').translate(bytes.fromhex('63748596a7b8c9da'))
  # 0x63748596 is a call, 0xa7b8c9da fills its delay slot and has no p-code
  disassembly  0x00000000: call     -0x722de9a8
  disassembly  0x00000004: impdef2  
  child died on signal 11 (SIGSEGV), subprocess returncode -11

$ Context('Toy:BE:32:default').translate(bytes.fromhex('f500a800'))
  # 0xf500 is a callds, 0xa800 fills its delay slot and has no p-code
  disassembly  0x00000000: callds   0x0
  disassembly  0x00000002: unimpl   
  child died on signal 11 (SIGSEGV), subprocess returncode -11

$ Context('sparc:BE:32:default').translate(bytes.fromhex('0100000063748596a7b8c9da'))
  # a nop ahead of the same call and delay slot
  disassembly  0x00000000: nop      
  disassembly  0x00000004: call     -0x722de9a4
  disassembly  0x00000008: impdef2  
  child died on signal 11 (SIGSEGV), subprocess returncode -11

$ Context('Toy:BE:32:default').translate(bytes.fromhex('d000f500a800'))
  # an and ahead of the same callds and delay slot
  disassembly  0x00000000: and      r0, r0
  disassembly  0x00000002: callds   0x2
  disassembly  0x00000004: unimpl   
  child died on signal 11 (SIGSEGV), subprocess returncode -11

After — the failure is a catchable UnimplError naming the branch at its own address, and a decodable instruction ahead of the branch is kept:

with this change, 2bbb34d
pypcode: .../wt/pypcode-288-head/pypcode/__init__.py
native : .../wt/pypcode-288-head/pypcode/pypcode_native.cpython-312-x86_64-linux-gnu.so

$ Context('sparc:BE:32:default').translate(bytes.fromhex('63748596a7b8c9da'))
  # 0x63748596 is a call, 0xa7b8c9da fills its delay slot and has no p-code
  disassembly  0x00000000: call     -0x722de9a8
  disassembly  0x00000004: impdef2  
  translate    UnimplError: Instruction not implemented in pcode:
   0x00000000: call  -0x722de9a8
  child exited 0

$ Context('Toy:BE:32:default').translate(bytes.fromhex('f500a800'))
  # 0xf500 is a callds, 0xa800 fills its delay slot and has no p-code
  disassembly  0x00000000: callds   0x0
  disassembly  0x00000002: unimpl   
  translate    UnimplError: Instruction not implemented in pcode:
   0x00000000: callds  0x0
  child exited 0

$ Context('sparc:BE:32:default').translate(bytes.fromhex('0100000063748596a7b8c9da'))
  # a nop ahead of the same call and delay slot
  disassembly  0x00000000: nop      
  disassembly  0x00000004: call     -0x722de9a4
  disassembly  0x00000008: impdef2  
  translate    1 p-code op(s), 1 instruction(s) kept
  child exited 0

$ Context('Toy:BE:32:default').translate(bytes.fromhex('d000f500a800'))
  # an and ahead of the same callds and delay slot
  disassembly  0x00000000: and      r0, r0
  disassembly  0x00000002: callds   0x2
  disassembly  0x00000004: unimpl   
  translate    6 p-code op(s), 1 instruction(s) kept
  child exited 0

@zardus

zardus commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

New since the 25 Aug comment: the trigger is a named function in shipped OpenSSL.

_sparcv9_vis2_probe in OpenSSL's crypto/sparccpuid.S is eight bytes in its entirety — retl and one .word the assembler could not encode — and those eight bytes crash pypcode:

$ python -c 'import pypcode; pypcode.Context("sparc:BE:32:default").translate(bytes.fromhex("81c3e00881b00980"))'
$ echo $?
139

https://github.com/openssl/openssl/blob/636dfadc70ce26f2473870570bfd9ec352806b1d/crypto/sparccpuid.S#L315-L317

Three more probes in that file end the same way: _sparcv9_vis3_probe 81c3e00881b022a0 (xmulx), _sparcv9_random 81c3e00891b002a0 (random), and the last two instructions of _sparcv9_fmadd_probe, 81c3e00881b80440 (fmaddd). All four take the process down on sparc:BE:32:default and on sparc:BE:64:default, eight of eight, on master 559aacdc. It is not four unlucky encodings. The SLEIGH spec defines 73 of the 512 opf values under op3=0x36 and none of the 512 under op3=0x37; the rest fall to two catch-alls, :impdef1 and :impdef2, both unimpl (SparcV9.sinc:1057-1058) — and an unimpl constructor in a delay slot is the path this PR fixes. The 73 that are defined work: _sparcv9_vis1_probe's tail 81c3e00881b00d80 returns 4 ops, on the same op3=0x36 as bshuffle.

Of the 17,946 SPARC objects in our sweep, five contain those sequences — all four of them, in every case — and all five are OpenSSL from NetBSD 10.1: lib/libcrypto.so.15.0, usr/lib/sparc/libcrypto.so.15.0, usr/bin/openssl, and two static libcrypto archives, where the containing member is sparccpuid.o / sparccpuid.po. CFGFast over each of the five dies with SIGSEGV on master (angr acfcdecb). 125 other NetBSD SPARC objects drawn at random produce no crash, so this is not a generic SPARC or NetBSD failure; it takes an instruction the spec has no p-code for, sitting in a delay slot.

The shared library is a plain download:

On this branch those eight bytes raise UnimplError instead, and the analysis finishes: CFGFast over that library completes with 17,927 functions and 149,958 blocks, none of them undecodable, where master dies in seconds.

This and #289 are both ours and both still open. The 25 Aug comment has the evidence on how the two interact; which one you merge is your call.

session: sharpen

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