Skip to content

Register ArchMIPSN32 so its name resolves to it - #385

Open
zardus wants to merge 1 commit into
masterfrom
feature/mipsn32-arch-id
Open

Register ArchMIPSN32 so its name resolves to it#385
zardus wants to merge 1 commit into
masterfrom
feature/mipsn32-arch-id

Conversation

@zardus

@zardus zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

arch_from_id answers the identifier MIPSN32 with ArchMIPS32:

>>> import archinfo
>>> archinfo.arch_from_id("MIPSN32").name
'MIPS32'
>>> archinfo.ArchMIPSN32.name
'MIPSN32'

The two are not interchangeable. n32 is a 64-bit instruction stream with 32-bit
pointers; MIPS32 has neither the same register file (a4-a7 exist only on the
64-bit one) nor the same instruction width. Anything that canonicalises an
architecture by round-tripping its name therefore gets a different architecture
back, and gets it silently.

angr does exactly that. SimLibrary.set_default_cc canonicalises its key with
archinfo.arch_from_id(arch_name).name, and procedures/definitions/linux_kernel.py
walks SYSCALL_CC calling it once per architecture. MIPS32 is written first and
MIPSN32 then lands on the same key and overwrites it:

>>> from angr.procedures.definitions.linux_kernel import lib
>>> {k: v.__name__ for k, v in lib.default_ccs.items() if "MIPS" in k}
{'MIPS32': 'SimCCN32LinuxSyscall', 'MIPS64': 'SimCCN64LinuxSyscall'}

Every syscall SimProcedure on a MIPS32 Linux project is then given the n32
convention, whose argument registers are a0-a7. On
binaries/tests/mips/busybox (MIPS32 big-endian, 3518 functions),
CompleteCallingConventions reaches the mmap/futex stubs and dies:

  File "angr/analyses/calling_convention/fact_collector.py", line 449, in _handle_function
    base_offset = self.project.arch.registers[loc.reg_name][0]
                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
KeyError: 'a4'

Nothing catches it, so the analysis is lost for the whole binary rather than for
the one function: after the raise 139 of 3518 functions carry a calling
convention.

Root cause

register_arch was never called for ArchMIPSN32 -- it is the only class in
archinfo.__all__ with a name and no arch_id_map entry. No rule claims its
identifiers, so arch_from_id falls through to ArchMIPS32's catch-all:

register_arch([r".*mips.*"], 32, "any", ArchMIPS32)

It is the only 32-bit MIPS rule there is, and arch_from_id reads bits=32
off the 32 in mipsn32, so the two MIPS64 rules are never in the running.
Nothing is wrong with SimCCO32 itself: an o32 function's fifth integer
argument is a stack slot, exactly as the ABI says, so no o32 convention can
produce an a4. It comes only from the wrong convention being attached.

An identifier answered with a different architecture is worse than
ArchNotFound, because the caller cannot see it happen.

Fix

ArchMIPS32's catch-all declines the n32 spellings, and arch_mips64.py
registers ArchMIPSN32 for them:

register_arch([r"(?!.*n32).*mips.*"], 32, "any", ArchMIPS32)
...
register_arch([r".*mips[-_]?n32(el|le).*"], 32, Endness.LE, ArchMIPSN32)
register_arch([r".*mips[-_]?n32.*"], 32, Endness.ANY, ArchMIPSN32)

The n32 rules sit after the 64-bit ones so that all_arches, which
register_arch appends to as well, keeps MIPS64 ahead of MIPSN32.
arch_from_id is insensitive to that placement. .*mips64.*|.*mips.* does
match the string mipsn32; it is the bits filter, not the pattern, that
declines it. One edge changes with it: arch_from_id("mips64-linux-gnuabin32", bits=32) now raises ArchNotFound where it used to answer ArchMIPS32. No
caller in cle or angr passes that combination.

With that, binaries/tests/mips/busybox completes the analysis and 3514 of 3518
functions get a calling convention, 3509 of 3518 a prototype, against 139 and
139 on the merge base.

After, every architecture archinfo exports resolves from its own name. By
class rather than by name one still does not, on both arms: ArchARM.name is
"ARMEL", which is the alias relationship those two have.

Two things deliberately not done. A reg_name in arch.registers guard at
fact_collector.py:449: it would stop the crash and leave every MIPS32 binary
analysed with n32 argument facts -- silently wrong instead of loudly wrong --
and angr#6356 is already working that seam. And the GNU triplets
mips64-linux-gnuabin32 / mips64el-linux-gnuabin32, which still resolve to
ArchMIPS64 because arch_from_id reads bits=64 out of the 64. That is a
family property, not something left half-done: the ArchARMHF and
ArchARMCortexM triplets resolve to ArchARMEL the same way on both arms.

One consequence is worth stating, because it is a loss. 12 of the 343 o32
syscall prototypes take a 64-bit argument (pread64, pwrite64, truncate64,
fallocate, readahead, sync_file_range, ...), and SimCCO32LinuxSyscall
does not override next_arg, so the base implementation refuses to split one
across two 32-bit slots:

ValueError: <SimCCO32LinuxSyscall> doesn't know how to store large types.
Consider overriding next_arg to implement its ABI logic

SimCCO32, the non-syscall o32 convention, lays out all 343 without raising,
and the n32 convention handled these 12 only because its argument slot is 8
bytes wide. The same 12 come back on the merge base, so this fix uncovers a gap
that was there all along rather than creating one. In a 60-function sample from
busybox it costs three functions their output (57 of 60 decompile to text
against 60 of 60 on the merge base), against 139 recovered conventions rising
to 3514 across the binary. That gap belongs to SimCCO32LinuxSyscall in angr
and is not fixed here.

Testing

tests/test_mips.py gains TestArchLookupByName, five cases: ArchMIPSN32 is
in all_arches; arch_from_id(ArchMIPSN32.name) is ArchMIPSN32; every class
in archinfo.__all__ that has a name resolves from it; every o32 identifier
that resolved to ArchMIPS32 before still does, in the endness it did before;
and the n32 identifiers carry their endness. tests/test_mips.py is 12 passed
on this head and 4 failed, 8 passed on the merge base with the tests kept; the
o32 control is one of the eight that pass on both sides, so it shows the
exclusion regex changed nothing there rather than that the suite is inert.
tests/ is 47 passed, 34 subtests passed on this head.

register_arch mutates all_arches, which angr walks in two places, and
archinfo's CI cannot see either; both were run against this head.
test_boyscout.py with test_stack_alignment.py is 16 passed, and
test_calling_convention_analysis.py with tests/procedures/ is 98 passed,
4 skipped.

Validation: #385 (comment)

session: sharpen

arch_from_id("MIPSN32") returned ArchMIPS32. ArchMIPSN32 was the only class in
archinfo.__all__ that register_arch was never called for, so no rule in
arch_id_map claimed its identifiers and the 32-bit MIPS catch-all took them.

The two architectures share neither a register file nor an instruction width, so
a caller that canonicalises an architecture through its own name got a different
architecture back.

ArchMIPS32's catch-all now declines the n32 spellings and arch_mips64.py claims
them, after the 64-bit rules so all_arches keeps MIPS64 ahead of MIPSN32.
@zardus

zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 3d90f6dbaa77b0681de20c764ced18e9d9c552f4 against baseline 06a207fd86460ea3a2c5e621f2c4c04ca22bd228.

Workspace gate

./feature.sh test mipsn32-arch-id, run 1942822, exit 0, PARTIAL PASS: 8 of 14
suites ran and passed
. A green gate that skipped a suite is green over less
than it appears to be, so here is the ledger rather than the headline:

workspace            ran, passed
test-inputs          ran, passed
test-packages        ran, passed
pre-commit           ran, passed
feature-build        ran, passed
mono                 ran, passed
archinfo             ran, passed      47 passed, 34 subtests passed
pysoot               ran, passed
worktree-cleanliness ran, passed; no checkout changed
pypcode              did NOT run: feature had not adopted pypcode
pyvex                did NOT run: feature had not adopted pyvex
cle                  did NOT run: feature had not adopted cle
angr                 did NOT run: feature had not adopted angr
angr-rust            did NOT run: feature had not adopted angr
angr-management      did NOT run: feature had not adopted angr-management

pyright, mypy and the merge-base lint comparison are not suites this gate
runs, so nothing here covers them and a CI prediction should not claim them.

angr suites run separately, after adopting angr into the feature

register_arch mutates two module globals, so this change reaches angr even
though the diff does not touch it: two ArchMIPSN32 instances now appear in
archinfo.all_arches. angr has two consumers of that list, and archinfo's own
CI cannot see either of them, so they were run here.

tests/sim/test_stack_alignment.py   iterates all_arches; now exercises MIPSN32
tests/analyses/test_boyscout.py     votes by walking all_arches in order
                                    -> 16 passed
tests/analyses/test_calling_convention_analysis.py
tests/procedures/
                                    -> 98 passed, 4 skipped

Neither MIPSN32 nor MIPS64 can reach BoyScout's vote in either registration
order: ArchMIPS64.function_prologs is the empty set, ArchMIPSN32 inherits it,
and boyscout.py:38 is if not arch.function_prologs: continue. Measured on
this head, the arches BoyScout reaches are MIPS32, PPC32, PPC64, S390X, X86 and
the ARM and x86-family entries -- no MIPS64, no MIPSN32.

The full angr suite did not run. What ran is the set of suites this change
can reach; hosted CI covers the rest and its result is the one a reviewer sees.

Change-specific measurements

Regression, on the merge base with the source reverted and the tests kept:

4 failed, 8 passed
  FAILED TestArchLookupByName::test_mipsn32_is_registered
  FAILED TestArchLookupByName::test_mipsn32_resolves_from_its_own_name
  FAILED TestArchLookupByName::test_every_named_arch_resolves_from_its_own_name
  FAILED TestArchLookupByName::test_n32_identifiers_carry_their_endness
  passed  TestArchLookupByName::test_o32_identifiers_are_untouched     <- control

and on this head, tests/test_mips.py is 12 passed.

binaries/tests/mips/busybox, MIPS32 big-endian, 3518 functions, CFGFast
normalised then CompleteCallingConventions(recover_variables=True, analyze_callsites=True):

merge base 06a207f head 3d90f6dba
CompleteCallingConventions raises KeyError('a4') completes
functions with a calling convention 139 / 3518 3514 / 3518
functions with a prototype 139 / 3518 3509 / 3518
first 60 non-PLT functions decompiled to text 60 / 60, 0 logged errors 57 / 60, 6 logged errors

Wall-clock figures are deliberately absent: two independent runs of the same
script disagreed by up to 34% on the same arm, while the counts above were
identical to the digit across those runs.

The three functions that stop are the disclosed SimCCO32LinuxSyscall
large-type gap: 12 of the 343 real o32 syscall prototypes, identical on both
arms. The output comment names them.

Environment

env      /nix/store/ms94xc6mcr8iqbq7b113bqf8qcl103gk-python3-3.12.13-env
tools    /nix/store/zlnwnwrv8ns6szx1ai66js3cw88l9cgr-angr-gate-tools
archinfo 3d90f6dbaa77b0681de20c764ced18e9d9c552f4  feature/mipsn32-arch-id  clean
angr     23b470d9f  master  clean
cle      0e77ade3c  master  clean
pyvex    2324a19e8  master  clean
pypcode  559aacdc9  master  clean
PYTHONHASHSEED=0

@zardus

zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Reproducer, on a fixture angr/binaries already tracks. binaries/tests/mips/busybox
is a big-endian MIPS32 ELF; every syscall stub in it is given a calling convention
through linux_kernel.lib.default_ccs.

import angr, archinfo
from angr.procedures.definitions.linux_kernel import lib as klib

print("arch_from_id('MIPSN32') ->", archinfo.arch_from_id("MIPSN32").name)
print("default_ccs           ->", {k: v.__name__ for k, v in klib.default_ccs.items() if "MIPS" in k})

p = angr.Project("binaries/tests/mips/busybox", auto_load_libs=False)
cfg = p.analyses.CFGFast(normalize=True)
p.analyses.CompleteCallingConventions(recover_variables=True, analyze_callsites=True, cfg=cfg.model)

print("with a calling convention:", sum(1 for f in p.kb.functions.values() if f.calling_convention is not None), "/", len(p.kb.functions))
print("with a prototype         :", sum(1 for f in p.kb.functions.values() if f.prototype is not None), "/", len(p.kb.functions))

No wall-clock figures are quoted below: two runs of this script disagreed by up
to 34% on the same arm. The counts reproduce to the digit.

Before -- archinfo 06a207f, angr 23b470d9f
arch_from_id('MIPSN32') -> MIPS32
default_ccs           -> {'MIPS32': 'SimCCN32LinuxSyscall', 'MIPS64': 'SimCCN64LinuxSyscall'}
ARCH: MIPS32
funcs: 3518
CCA: RAISED KeyError('a4')
functions with a recovered calling convention: 139 / 3518
functions with a recovered prototype        : 139 / 3518

with, from the raise:

  File "angr/analyses/complete_calling_conventions.py", line 236, in work
    cc, proto, proto_libname, proto_source, _ = self._analyze_core(func_addr)
  File "angr/analyses/complete_calling_conventions.py", line 451, in _analyze_core
    cc_analysis = self.project.analyses[CallingConventionAnalysis].prep(kb=self.kb)(
  File "angr/analyses/calling_convention/calling_convention.py", line 279, in _analyze
    facts = self.project.analyses[FactCollector].prep(kb=self.kb)(
  File "angr/analyses/calling_convention/fact_collector.py", line 382, in _analyze_startpoint
    self._handle_function(state, func)
  File "angr/analyses/calling_convention/fact_collector.py", line 449, in _handle_function
    base_offset = self.project.arch.registers[loc.reg_name][0]
                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
KeyError: 'a4'

The callee at the raise, read out of the failing frame:

callee=futex  cc=SimCCN32LinuxSyscall  cc.ARCH=archinfo.arch_mips64.ArchMIPSN32
cc.ARG_REGS=['a0','a1','a2','a3','a4','a5','a6','a7']
project.arch=MIPS32, arch.registers has a0 a1 a2 a3 only
project.simos.syscall_cc(state) -> SimCCO32LinuxSyscall     # the correct one
After -- archinfo 3d90f6d, angr 23b470d9f
arch_from_id('MIPSN32') -> MIPSN32
default_ccs           -> {'MIPS32': 'SimCCO32LinuxSyscall', 'MIPS64': 'SimCCN64LinuxSyscall', 'MIPSN32': 'SimCCN32LinuxSyscall'}
ARCH: MIPS32
funcs: 3518
CCA: OK
functions with a recovered calling convention: 3514 / 3518
functions with a recovered prototype        : 3509 / 3518
Decompiler, same fixture, first 60 non-PLT non-SimProcedure functions by address
merge base  60 of 60 decompile to text, 0 logged analysis errors
this head   57 of 60 decompile to text, 6 logged analysis errors

The three that stop are sub_405760, sub_405848 and sub_405950, all with:

ValueError: <SimCCO32LinuxSyscall> doesn't know how to store large types.
Consider overriding next_arg to implement its ABI logic

That is the correct o32 syscall convention meeting a 64-bit argument it cannot
split. klib.syscall_prototypes["mips-o32"] has 360 entries, 17 of which are
None because the prototype was never parsed; over the 343 real ones:

SimCCO32LinuxSyscall  refuses 12 of 343
SimCCO32              refuses  0 of 343
SimCCN32LinuxSyscall  refuses  0 of 343

the 12: fadvise64 fadvise64_64 fallocate fanotify_mark ftruncate64
        lookup_dcookie pread64 pwrite64 readahead sync_file_range
        sync_file_range2 truncate64

Identical on the merge base, so the gap is in SimCCO32LinuxSyscall, in angr,
and predates this change. SimCCN32LinuxSyscall laid these 12 out only because
its argument slot is 8 bytes wide.

@angr-bot

Copy link
Copy Markdown
Member

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

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