Skip to content

Keep the decoded prefix when MIPS32 meets a MIPS64-only instruction - #567

Open
zardus wants to merge 1 commit into
masterfrom
feature/mips64-nodecode
Open

zardus wants to merge 1 commit into
masterfrom
feature/mips64-nodecode

Conversation

@zardus

@zardus zardus commented Aug 16, 2026 •

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

A MIPS64-only encoding met by the 32-bit MIPS guest costs the caller the whole superblock, not just that instruction. The block reported in #76 is six ordinary MIPS32 instructions followed by daddu $a0, $v0, $zero (0x0040202d) at 0x3c5f0:

$ pyvex.lift(8fc2002c ... 8f998324, 0x3c5d8, ARCH_MIPS32_BE, opt_level=0)
  size=0  instructions=0  jumpkind=Ijk_NoDecode  next=0x0003c5d8
  instruction_addresses=()

next is the address the block started at, so a caller that follows it re-lifts the same bytes and gets the same empty block. Every one of the fifteen encodings the regression covers behaves this way.

Root cause

guest_mips_toIR.c decodes MIPS64-only encodings without consulting mode64, assigns the 64-bit result to a 32-bit guest register, and libVEX longjmps out of the translation -- putIReg's vassert(typeOfIRExpr(irsb->tyenv, e) == ty) for ld, the IR sanity check (Iex.Binop: arg tys don't match op tys) for dsll32, vassert(mode64) for dmtc1. Sweeping 114,719 encodings against a 32-bit guest finds 16,786 of them losing the whole superblock this way.

Nothing above it recovers the decoded prefix, because there is nothing left to recover: the C side returns size == 0, so LibVEXLifter._lift raises

            if self.irsb.size == 0:
                raise LiftingException("libvex: could not decode any instructions @ 0x%x" % self.addr)

and lift() falls through its for ... else to IRSB.empty_block(arch, addr, size=0, nxt=Const(addr), jumpkind="Ijk_NoDecode"). The path that would extend and re-lift is guarded on final_irsb.size > 0 and final_irsb.jumpkind == "Ijk_NoDecode", which a zero-size block never satisfies.

Fix

Bump the vex submodule to angr/vex#91, which rejects those encodings through the same decode_failure path every other unsupported encoding uses. The block then ends at the offending address with the prefix intact:

  size=24  instructions=7  jumpkind=Ijk_NoDecode
  instruction_addresses=(0x3c5d8, 0x3c5dc, 0x3c5e0, 0x3c5e4, 0x3c5e8, 0x3c5ec, 0x3c5f0)

No Python changes; the submodule bump and tests/test_mips32_reserved.py are the whole diff.

Testing

tests/test_mips32_reserved.py names one encoding per affected group -- ld, daddiu, daddu, dsll, dsll32, dsrl32, dmult, ldl, ldr, sdl, sdr, dmtc1, dext, dsbh, dclz, and the two OCTEON bbit032/bbit132 branches, seventeen in all -- and asserts for each that the block keeps its two-instruction prefix, that next is the reserved address, and that Ijk_NoDecode is still the jumpkind. On the merge base the file is 2 failed, 4 passed -- the non-branch test stopping at its first entry with assert 0 == 8, the branch test failing the same way on bbit032. At this head the file is 6 passed and the whole suite is 93 passed.

The remaining tests pin that MIPS64 still decodes every one of those encodings, and that seven encodings a coarser guard would have swallowed still lift with their prefix intact on MIPS32. sd $ra, 0x5c8($sp), lwu $a0, 0x10($v1) and the 32-bit bbit0/bbit1 decode, their opcodes being ones the guard never covers; the MIPSR6 dmul form that shares dmult's function code, a COP1 encoding that shares dmfc1's rs but sets the low bits, and a dbshfl that is neither dsbh nor dshd raise ILLEGAL_INSTRUCTON. A two-build A/B over 114,719 encodings per guest is what caught a coarser first draft of the guard taking 111 of them away, so the narrowing is tested rather than asserted.

This removes the crash #76 reports; it does not make those objects decode past the reserved instruction, which is what the n32 architecture, lifter, loader and consumer changes do. Validation: #567 (comment)

Merge order: angr/vex#91 first. This head's vex submodule is that pull request's head, one commit ahead of vex master, so merging this half on its own leaves pyvex master's submodule pointing at a commit that is on no vex branch. angr/vex is not in the CI repository list, so the sync: line below is a note to the reader rather than an instruction to CI.

sync: angr/vex#91

session: sharpen

@zardus

zardus commented Aug 16, 2026 •

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 8737c95a154103a750b11082f792cc41693ef66f against baseline 24e2bd862aaedc14887bd247f1f1130445f94db6.

Re-ported 2026-09-16, and this record replaces every figure that stood here before. The previous head dca28718 pinned vex at 561795c1, written against libVEX before the Valgrind 3.27.1 import. That import rewrote priv/guest_mips_toIR.c by +22,145/-14,229 and changed this repository's CMakeLists.txt source list, so the old head neither merges nor builds against master: git merge-tree conflicts on the submodule pointer, and CMake fails with Cannot find source file: vex/priv/s390_disasm.c. The patch was written again against the new decoder, so this is a new candidate, not a rebase. The corpus and CFG figures that were here -- the 1,000,961-encoding lifter sweep, the six ELF32 MIPS III images, the 447 MIPS control objects and the 50 angr/binaries fixtures -- were measured on the old libVEX and against a predicate that has since changed. They are withdrawn, not carried forward, and have not been retaken.

repository revision
pyvex 8737c95 (this PR head)
vex bc077e2 (angr/vex#91 head)
pyvex base 24e2bd8
vex base e306287

Regression and suite

  • Regression: pytest --import-mode=append -q tests/test_mips32_reserved.py -- 2 failed, 4 passed against the two bases, 6 passed at this head. The two failures are the tests that assert the prefix survives: the non-branch one stops at its first entry with AssertionError: ld $a0, -0x3098($v1) assert 0 == 8, the block being empty where the prefix should be, and the branch one fails the same way on bbit032. The other four pass on both sides, which is their job -- they pin what the change must not move.
  • Full suite: pytest --import-mode=append -q tests/ -- 93 passed.
  • Native syntax check on the vex side: gcc -std=gnu99 -DAVX_512 -Ipub -Ipriv -Wall -Wextra -Werror=implicit-function-declaration -fsyntax-only priv/guest_mips_toIR.c -- exit 0, diagnostic set identical to the baseline file's (8 -Wsign-compare, 10 -Wunused-parameter, all pre-existing). A positive control with an undeclared call exits 1, so the instrument is known to speak.

All of the above run through the store environment the workspace builds, never a virtual environment; the package under test is the store build of these worktrees.

The block issue #76 reports

pyvex.lift(bytes.fromhex("8fc2002c...8f998324"), 0x3c5d8, ARCH_MIPS32_BE, opt_level=0) -- six MIPS32 instructions then daddu $a0, $v0, $zero at 0x3c5f0, measured on both builds today:

baseline (vex e306287): size=0  instructions=0  jumpkind=Ijk_NoDecode  next=0x0003c5d8
head     (vex d70134c): size=24 instructions=7  jumpkind=Ijk_NoDecode  next=0x0003c5f0

next on the baseline is the block's own start address, so a caller that follows it re-lifts the same bytes and gets the same empty block.

Encoding-space A/B

Two builds of this worktree differing only in priv/guest_mips_toIR.c, each in its own Nix store path. 114,719 encodings per guest: every (opcode, function) and (opcode, rs) slot with three register and six immediate patterns each, plus a seeded sample of 100,000 from the whole 32-bit space. Each is lifted behind two decodable instructions and two trailing nops, and compared on size, jumpkind, next and instruction count.

guest encodings changed abort becomes a clean failure keeping the prefix translations lost
MIPS32 BE 114,719 16,786 16,786 0
MIPS32 LE 114,719 16,786 16,786 0
MIPS64 BE 114,719 0 0 0
MIPS64 LE 114,719 0 0 0

Every change is in one direction, and no encoding this configuration decodes today stops decoding. Not one of the 114,719 results differs on either 64-bit guest, which is the check that the guard is inert when mode64 is set. The claim is about the guest pyvex builds -- Cavium with MIPS32R1 and R2 -- because that is the only one these bindings can construct.

What the sweep caught before publication

A first re-port tested only the opcode and function, as the pre-import patch did, and this A/B showed it taking away 111 translations on MIPS32 BE, plus 36 encodings whose clean Ijk_SigILL it turned into Ijk_NoDecode. The import had given those slots sub-switches: SPECIAL function 0x1C-0x1F also hold the MIPSR6 DMUL/DMUH/DDIV/DMOD group selected by sa, and COP1 rs 1 and 5 are DMFC1/DMTC1 only when the low 11 bits are clear. Those two account for all 111: 104 in SPECIAL and 7 in COP1. SPECIAL3 DBSHFL is narrowed on sa for the same reason but contributed none of them, because the encodings it would have swallowed already raise ILLEGAL_INSTRUCTON rather than decoding. test_the_guard_is_narrower_than_the_mnemonic pins one encoding per narrowed slot, each differing from a rejected encoding only in the deciding field.

A second finding, from reading rather than from the A/B: three whole opcodes change meaning on a MIPSR6 guest, where the same bytes are a 32-bit instruction this file decodes -- DADDI is BOVC/BEQC/BEQZALC, BBIT032 and BBIT132 are JIC/BEQZC and JIALC/BNEZC. The predicate therefore takes hwcaps and rejects those three only when the guest is not MIPSR6. It does not stand down for the three narrowed slots, because there the MIPSR6 encodings sit at other values of the field being narrowed on and the covered values build 64-bit IR regardless of hwcaps. That arm is unmeasured here: pyvex_c/pyvex.c pins VexArchMIPS32 to Cavium with MIPS32R1 and R2 and overrides the caller's VexArchInfo, so no MIPSR6 MIPS32 guest can be lifted through these bindings and the A/B below covers that one configuration only.

Two groups also moved relative to the previous head. LLD and SCD are no longer covered, because the import gave them their own mode64 test and they now raise ILLEGAL_INSTRUCTON on a 32-bit guest. The Cavium OCTEON BBIT032 and BBIT132 branches are now covered: all 1,778 and 1,763 sampled encodings in those slots abort on a 32-bit guest with the delay slot supplied, while their 32-bit siblings BBIT0 and BBIT1 decode and are left alone.

Caveats: the sweep covers the encoding space, not real binaries. This removes the lost superblock; it does not make MIPS n32 objects decode past the reserved instruction, which is what the architecture, lifter, loader and consumer changes do. 455 of the 114,719 sampled encodings still abort on a 32-bit guest in SPECIAL2, SPECIAL3 and COP1 slots that are not a clean MIPS64-only group; this change does not address them.

@angr-bot

Copy link
Copy Markdown
Member

Corpus decompilation diffs can be found at angr/dec-snapshots@master...angr/pyvex_567

@zardus

zardus commented Aug 28, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

The block reported in #76 -- six MIPS32 instructions then daddu $a0, $v0, $zero (0x0040202d) at 0x3c5f0 -- lifted as MIPS32, and one encoding from each of the fifteen affected groups behind a two-instruction prefix at 0x80010938, before and after this change.

Before -- lift() returns a zero-size Ijk_NoDecode block whose next is the block's own start address, so the six instructions ahead of the reserved encoding are gone and there is no address to resume from:

pyvex master (bdd5441, vex 875f7c9)
pyvex: .../wt/mips-base/pyvex/__init__.py

$ pyvex.lift(8fc2002cac4000008fc200148fc3000c8fc6002427c800100040202d0060282d8fc700288f998324, 0x3c5d8, ARCH_MIPS32_BE, opt_level=0)
  size=0  instructions=0  jumpkind=Ijk_NoDecode  next=0x0003c5d8
  instruction_addresses=()

IRSB {
   

   NEXT: PUT(pc) = 0x0003c5d8; Ijk_NoDecode
}

$ for each group, pyvex.lift(3c038004 2462cf68 <encoding>, 0x80010938, ARCH_MIPS32_BE)
  encoding                 word       size insns next
  ld    $a0, -0x3098($v1)  dc64cf68      0     0 0x80010938
  daddiu $v0, $v1, 0x7d    6462007d      0     0 0x80010938
  daddu $v0, $v1, $a0      0064102d      0     0 0x80010938
  dsll  $v0, $v0, 0x17     000215f8      0     0 0x80010938
  dsll32 $v1, $v1, 0       0003183c      0     0 0x80010938
  dsrl32 $v0, $v0, 0x14    0002153e      0     0 0x80010938
  dmult $v0, $v1           0043001c      0     0 0x80010938
  ldl   $t0, 0($v0)        68480000      0     0 0x80010938
  ldr   $t0, 7($v0)        6c480007      0     0 0x80010938
  sdl   $t0, 0($a2)        b0c80000      0     0 0x80010938
  sdr   $t0, 7($a2)        b4c80007      0     0 0x80010938
  dmtc1 $zero, $f0         44a00000      0     0 0x80010938
  dext  $a1, $v1, 0, 1     7c650003      0     0 0x80010938
  dsbh  $a1, $a0           7c0428a4      0     0 0x80010938
  dclz  $a1, $v1           70642824      0     0 0x80010938

After -- the reserved encoding takes vex's ordinary decode_failure path, so the block carries every instruction ahead of it and ends at the offending address:

with this change (dca2871, vex 561795c)
pyvex: .../wt/mips-567/pyvex/__init__.py

$ pyvex.lift(8fc2002cac4000008fc200148fc3000c8fc6002427c800100040202d0060282d8fc700288f998324, 0x3c5d8, ARCH_MIPS32_BE, opt_level=0)
  size=24  instructions=7  jumpkind=Ijk_NoDecode  next=t21
  instruction_addresses=('0x3c5d8', '0x3c5dc', '0x3c5e0', '0x3c5e4', '0x3c5e8', '0x3c5ec', '0x3c5f0')

IRSB {
   t0:Ity_I32 t1:Ity_I32 t2:Ity_I32 t3:Ity_I32 t4:Ity_I32 t5:Ity_I32 t6:Ity_I32 t7:Ity_I32 t8:Ity_I32 t9:Ity_I32 t10:Ity_I32 t11:Ity_I32 t12:Ity_I32 t13:Ity_I32 t14:Ity_I32 t15:Ity_I32 t16:Ity_I32 t17:Ity_I32 t18:Ity_I32 t19:Ity_I32 t20:Ity_I32 t21:Ity_I32

   00 | ------ IMark(0x3c5d8, 4, 0) ------
   01 | t6 = GET:I32(r30)
   02 | t5 = Add32(t6,0x0000002c)
   03 | t0 = t5
   04 | t7 = LDbe:I32(t0)
   05 | PUT(r2) = t7
   06 | PUT(pc) = 0x0003c5dc
   07 | ------ IMark(0x3c5dc, 4, 0) ------
   08 | t9 = GET:I32(r2)
   09 | t8 = Add32(t9,0x00000000)
   10 | t1 = t8
   11 | STbe(t1) = 0x00000000
   12 | PUT(pc) = 0x0003c5e0
   13 | ------ IMark(0x3c5e0, 4, 0) ------
   14 | t11 = GET:I32(r30)
   15 | t10 = Add32(t11,0x00000014)
   16 | t2 = t10
   17 | t12 = LDbe:I32(t2)
   18 | PUT(r2) = t12
   19 | PUT(pc) = 0x0003c5e4
   20 | ------ IMark(0x3c5e4, 4, 0) ------
   21 | t14 = GET:I32(r30)
   22 | t13 = Add32(t14,0x0000000c)
   23 | t3 = t13
   24 | t15 = LDbe:I32(t3)
   25 | PUT(r3) = t15
   26 | PUT(pc) = 0x0003c5e8
   27 | ------ IMark(0x3c5e8, 4, 0) ------
   28 | t17 = GET:I32(r30)
   29 | t16 = Add32(t17,0x00000024)
   30 | t4 = t16
   31 | t18 = LDbe:I32(t4)
   32 | PUT(r6) = t18
   33 | PUT(pc) = 0x0003c5ec
   34 | ------ IMark(0x3c5ec, 4, 0) ------
   35 | t20 = GET:I32(r30)
   36 | t19 = Add32(t20,0x00000010)
   37 | PUT(r8) = t19
   38 | PUT(pc) = 0x0003c5f0
   39 | ------ IMark(0x3c5f0, 0, 0) ------
   40 | PUT(pc) = 0x0003c5f0
   41 | PUT(pc) = 0x0003c5f0
   42 | t21 = GET:I32(pc)
   NEXT: PUT(pc) = t21; Ijk_NoDecode
}

$ for each group, pyvex.lift(3c038004 2462cf68 <encoding>, 0x80010938, ARCH_MIPS32_BE)
  encoding                 word       size insns next
  ld    $a0, -0x3098($v1)  dc64cf68      8     3 0x80010940
  daddiu $v0, $v1, 0x7d    6462007d      8     3 0x80010940
  daddu $v0, $v1, $a0      0064102d      8     3 0x80010940
  dsll  $v0, $v0, 0x17     000215f8      8     3 0x80010940
  dsll32 $v1, $v1, 0       0003183c      8     3 0x80010940
  dsrl32 $v0, $v0, 0x14    0002153e      8     3 0x80010940
  dmult $v0, $v1           0043001c      8     3 0x80010940
  ldl   $t0, 0($v0)        68480000      8     3 0x80010940
  ldr   $t0, 7($v0)        6c480007      8     3 0x80010940
  sdl   $t0, 0($a2)        b0c80000      8     3 0x80010940
  sdr   $t0, 7($a2)        b4c80007      8     3 0x80010940
  dmtc1 $zero, $f0         44a00000      8     3 0x80010940
  dext  $a1, $v1, 0, 1     7c650003      8     3 0x80010940
  dsbh  $a1, $a0           7c0428a4      8     3 0x80010940
  dclz  $a1, $v1           70642824      8     3 0x80010940

libVEX decoded about thirty MIPS64-only encodings on a 32-bit MIPS guest and
assigned 64-bit values to 32-bit guest registers, which failed either
vassert(mode64) or the IR sanity check and unwound the whole translation.
LibVEXLifter._lift then saw size == 0 and raised, and lift() fell through to
an empty IRSB whose Ijk_NoDecode pointed at the start of the block rather than
at the instruction that could not be decoded -- so the instructions decoded
ahead of it were lost, and the "decode more bytes and extend" path never ran,
because it is guarded on final_irsb.size > 0.

Carry the vex fix and add the regression tests: the prefix survives for one
encoding from each affected group and for the two OCTEON branches, MIPS64
still decodes all of them, and the encodings that share those opcode slots
with a 32-bit instruction -- SD, LWU, BBIT0, BBIT1, the MIPSR6 DMUL group, a
COP1 format with the low bits set, and the rest of DBSHFL -- still decode, so
a guard that was too coarse would fail the suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zardus
zardus force-pushed the feature/mips64-nodecode branch from dca2871 to 8737c95 Compare September 16, 2026 02:10
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.

2 participants