Skip to content

Load and relocate DOS MZ executables - #837

Merged
ltfish merged 1 commit into
masterfrom
feature/dos-mz-backend
Sep 11, 2026
Merged

ltfish merged 1 commit into
masterfrom
feature/dos-mz-backend

Conversation

@zardus

@zardus zardus commented Sep 11, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

cle cannot load an MS-DOS executable. Every MZ file whose header belongs to a DOS
program rather than to a stub is refused:

$ python -c "import cle; cle.Loader('binaries/tests/i386/dos/hello.exe', auto_load_libs=False)"
Traceback (most recent call last):
  ...
  File ".../cle/loader.py", line 1010, in _load_object_isolated
    raise CLECompatibilityError(
cle.errors.CLECompatibilityError: Unable to find a loader backend for
binaries/tests/i386/dos/hello.exe.  Perhaps try the 'blob' loader?

That is angr/angr#48, open since December 2015: "this is a
MS-DOS executable, which is not currently supported by CLE. It wouldn't be too hard
to write a loader for this format".

The blob load the error suggests is not a substitute, because it cannot apply the
header's relocation table -- it reports no relocations at all. Of 1,648 MZ objects in
a corpus measured for this change, 1,152 declare a non-empty relocation table, and
every word those tables name keeps its link-time value under a blob load.

Root cause

No registered backend claims the container, and two things stand in the way of one.

The first is the relocation table: a DOS executable is linked at segment 0 and DOS
adds the run-time load segment to every word the table names. That is a real
relocation, so it belongs to a backend rather than to a byte-blob recipe.

The second is that CLE equates the address space with the register width.
Loader._is_range_free rejects any placement past 2**self.main_object.arch.bits,
and _find_safe_rebase_addr bounds its search the same way:

if va < 0 or va + size > 2**self.main_object.arch.bits:
    return False

Real mode has 16-bit registers and a 20-bit address space, so that confines a
real-mode object to 64 KiB and refuses to place anything larger. arch.bits is the
right answer for the register width and the wrong answer for the address space, and
they are not the same question.

Fix

cle.backends.mz.MZ, registered as mz and probed by default. It maps the load
module in a flat 20-bit space at segment * 16 + offset, takes its architecture from
archinfo.ArchPcode("x86:LE:16:Real Mode"), sets os = "dos", and turns each header
relocation into an MZRelocation that adds the load segment to the word it names.
The p-code real-mode language already resolves far pointers to flat linear addresses,
so no segmented-address machinery is needed.

To separate the address space from the register width, Backend grows a
mapped_address_bits property defaulting to self.arch.bits, and the two lines of
cle/loader.py above read it instead. MZ overrides it to 20; nothing else changes
behaviour.

Two header fields need care. e_cs and e_ss are relative segments that DOS adds to
the load segment in 16-bit arithmetic, so a high segment word is ambiguous: a linker
writes 0xfff0 for the sixteen paragraphs below the load module, where the Program
Segment Prefix sits, and a large enough image reaches a high segment counting
upwards. _resolve_relative tries both readings and takes the one in range. They are
exactly one real-mode address space apart, so a half-open bound admits at most one of
them; with an inclusive bound the literal reading can land on the ceiling while the
wrapped one lands on zero, both pass, and there is nothing to choose between them.
One of the fixtures has that header and a test pins it.

And e_lfanew sits at offset 0x3c whatever the header's paragraph count says, so
the check that refuses PE, NE, LE, LX, W3, W4, P2, P3, PL and PM containers is not
gated on the declared header size — PE images exist that declare a two-paragraph
header and still carry a real pointer there.

Deliberately not done: .COM files, for which blob is an adequate recipe; the NE,
LE, LX and PE containers this backend refuses; DOS extenders whose real program lives
past the stub; overlay modules; and the Program Segment Prefix, the uninitialised
allocation and trailing overlays, none of which are synthesised.

Testing

tests/test_mz.py, 10 tests over the three fixtures in the sibling pull request.
They assert the backend is selected without being named, the header fields against
the file's bytes, the entry point against the instructions at CS:IP, that each of
five relocation sites reads file_word + load_segment after a rebase, that a zero
load segment leaves the image byte-identical to the file, that a wrapped e_cs and
e_ss resolve into the load module and name the PSP after a rebase, that the wrapped
stack whose literal reading sits on the 1 MiB ceiling resolves to the start of the
image rather than past the top of the address space, that a tracked PE still loads as
a PE, and that a .COM file is not claimed. The module skips itself where pypcode is
absent, as tests/test_arch_detect.py already does.

Over a corpus of 2,123 distinct DOS-candidate binaries, cle.Loader loads 0 on
master and 1,571 with this change; the 552 that still fail are containers this
backend deliberately refuses, chiefly headerless .COM images and LE extenders. That
corpus is private and cannot be published, so the three fixtures are the public
reproducer. Before this change angr/binaries tracked 106 files beginning with the
MZ magic and every one was a PE; all 106 still load, and none is claimed by the new
backend. The validation record has the full breakdown and the controls.

Merge angr/binaries#232 first: these tests load its fixtures.

Fixes angr/angr#48. Validation: #837 (comment)

sync: angr/binaries#232

session: sharpen

cle refuses every MS-DOS executable with "Unable to find a loader backend", so a
whole class of real-mode binaries is unreachable, and the blob recipe the error
suggests cannot apply the header's relocation table: a DOS executable is linked
at segment 0 and DOS adds the run-time load segment to every word the table
names.

Add cle.backends.mz.MZ. It maps the load module in a flat 20-bit space at
segment * 16 + offset, takes its architecture from the p-code real-mode
language, and turns each header relocation into an MZRelocation that adds the
load segment to the word it names. The p-code language already resolves far
pointers to flat linear addresses, so no segmented-address machinery is needed.

Real mode has 16-bit registers and a 20-bit address space, and cle equated the
two: Loader._is_range_free and _find_safe_rebase_addr both bounded placement by
2 ** arch.bits, which confines a real-mode object to 64 KiB and fails to place
anything larger. Backend grows a mapped_address_bits property defaulting to
arch.bits, the two loader sites read it, and MZ overrides it to 20. Nothing
else changes behaviour.

Two header fields need care. e_cs and e_ss are relative segments DOS adds to
the load segment in 16-bit arithmetic, so a high segment word is ambiguous: a
linker writes 0xfff0 for the sixteen paragraphs below the load module, where
the Program Segment Prefix sits, and a large enough image reaches a high segment counting
upwards. _resolve_relative takes whichever reading lands in range, and the
bounds are half-open so at most one of them can. And e_lfanew sits at 0x3c
whatever the header's paragraph count says, so the check that refuses PE, NE,
LE, LX, W3, W4, P2, P3, PL and PM containers is not gated on the declared
header size.

.COM files, the extended containers, DOS extenders whose real program lives
past the stub, overlay modules, and the Program Segment Prefix are all out of
scope.
@zardus

zardus commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head 9167c8b6d412a757e0d3a72cd670fba639d539ee against baseline 0e77ade3c39a3cee05f65051e57955675e1ac21b.

Which tree was tested

The gate below was launched one commit earlier, at c780e041, and its environment
record names that commit rather than the head above. The two commits have the same
tree, 5520ae43f15f6f77eea5c9a925f7a526c1261cfb: the later one only rewords the
commit message. git diff --name-only c780e041 9167c8b6 prints nothing, against a
control -- the same command over the pair before it -- which prints
tests/test_mz.py. So the gate ran on the tree being published, and the tree hash
rather than the commit id is what links them.

The workspace gate

ANGR_TEST_JOBS=3 ./feature.sh test dos-loader          2129 s, exit 0

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    pysoot        ran, passed    cle           ran, passed
angr       ran, passed    angr-rust     ran, passed
pypcode          did NOT run: feature dos-loader has not adopted pypcode
pyvex            did NOT run: feature dos-loader has not adopted pyvex
angr-management  did NOT run: feature dos-loader has not adopted angr-management
worktree-cleanliness ran, passed; 1 checkout(s) changed under this run but not by it

PARTIAL PASS: 11 of 14 suites ran and passed; 3 did NOT run.

cle 271 passed, 9 skipped; angr 2974 passed, 47 skipped, 2 xfailed, 286 subtests; archinfo 42 passed, 34 subtests; mono 139 tests OK, 2 skipped. No
NOT BUILT,
TEST PATHS DESELECTED, GATE REFUSES or COMPONENTS ABSENT. The three suites
that did not run are repositories this feature collection has not adopted; nothing
here touches them.

worktree-cleanliness passed with a qualifier rather than silently, and the log
carries WORKTREE CHANGED UNDER THIS RUN, NOT BY IT. Another agent was working in a
different checkout on the same machine throughout, and eight of its paths entered or
left that checkout's dirty set while this ran. None of them is this change's, and
they are not inert: the workspace suite's third count was 711 here where three
earlier runs of the same suite reported 701 -- the difference being a then-untracked test file of
that agent's, which unittest discover collects along with everything else in the
directory. That count is a property of that checkout on 2026-09-11, not of this
change, which no suite in that group reads.

The suites that bear on this change

pytest --import-mode=append -q tests/test_mz.py     10 passed
./feature.sh test dos-loader cle                   271 passed, 9 skipped

tests/test_mz.py skips itself where pypcode is absent, because the backend takes
its architecture from the p-code real-mode language and there is no object to assert
on without it. tests/test_arch_detect.py in this repository already guards itself
the same way. It matters for Test (Pyodide), whose dependency group does not
install pypcode and which neither cle nor archinfo requires it through.

The guard was checked in the direction that could fail, not only the one that
passes. A scratch copy of the module with its import pypcode replaced by a raised
ImportError, and nothing else changed, reports 10 skipped where the module as
committed reports 10 passed under the same command. A skip guard can also fail by
succeeding -- skipping its ten tests everywhere is indistinguishable from a passing
suite except by the count -- and the cle suite reports 271 passed, 9 skipped both
here and before the guard existed, so all ten still run. The environment this gate
builds carries pypcode, so no suite in it takes the skip path.

The built cle package is y2k2an70hi6vnbkxd1rsx2s25mqll6xc-python3.12-cle-9.3.5.dev0,
the same store path as the previous run. That is a confirmation of an inference and
not an independent measurement: the earlier run changed a file inside the package
and the path moved, which is what established that the source filter keeps
production source and drops tests/; this change is confined to tests/, so the
path could not have moved.

Lint and type comparison against the baseline

run-ci-diff-checks.py --repository cle, which compares
every touched file with its state on the baseline rather than against an absolute
bar:

ok   cle/__init__.py:           errors 0 -> 0
ok   cle/backends/__init__.py:  errors 0 -> 0
ok   cle/backends/backend.py:   errors 0 -> 0
ok   cle/backends/mz.py:        errors 0 -> 0
ok   cle/loader.py:             errors 5 -> 5
ok   tests/test_mz.py:          errors 0 -> 0
No lint or type regressions against the base revision.

That took two rounds of repair to reach: pyright first reported 14 errors in
tests/test_mz.py, every one a fixture attribute read through the declared
Backend type, and 1 in mz.py for self.owner.load_segment. Neither is visible
to the test suites.

Corpus

A private third-party collection of DOS-era binaries, 2,128 distinct sha256 in its
manifest, of which 2,123 exist on disk. Every figure in this section is over those
2,123. Loading only: cle.Loader(path, auto_load_libs=False) with no backend
forced, four workers, a 30-second per-object timeout, no timeouts and no memory
kills in either arm.

                        baseline 0e77ade3   this head
objects loaded                          0        1571
objects failed                       2123         552

On the baseline all 2,123 fail with one message, CLECompatibilityError: Unable to find a loader backend. At this head every success is the new backend, and all 1,571
are among the 1,648 objects whose first two bytes are MZ.

The 552 that still fail, grouped by the check that refuses each:

475  no MZ header at all: 469 with no signature, 6 shorter than the 0x1c
     fixed header -- chiefly headerless .COM images
 51  MZ stubs whose extended header names another format: 43 LE, 3 NE,
     3 P3, 1 PL, 1 PM
 23  a non-zero e_ovno
  3  malformed: 1 stack beyond what DOS would give, 1 relocation past the
     load module, 1 entry outside the load module

Independent check on the first class: exactly 475 of the 2,123 do not begin with
MZ or ZM, reached the other way round. All 552 fail at backend selection; not
one reaches MZ.__init__, so no CLEInvalidBinaryError escapes the loader.

That e_ovno group is not 23 overlay modules. 12 are DJGPP images whose e_ovno
word holds the ASCII st of \r\nstub, refused for the right outcome and the wrong
stated reason. The remaining 11 were not characterised, and at least three carry a
compressor's signature in the bytes just past that word, so the field should not be
read as an overlay number for them either.

28 of the 1,571 carry a protected-mode label in the corpus's own classification. For
the 17 DOS/16M and 4 DJGPP images among them the extended-header pointer holds
garbage rather than a signature, so what loads is the genuine real-mode stub program
rather than the protected-mode image behind it.

153 objects separate the 1,418 arm measured mid-development from the 1,571 arm.
Reading their header bytes splits them between the two changes that could have moved
them: 147 by the stack bound, and 6 by taking whichever reading of
e_cs/e_ss lands in range
-- objects whose relative segment is a genuine offset
rather than a displacement below the load module, which a plain signed read resolves
outside the image. A signed read already placed the entry of all 83 objects carrying
the .COM-style fff0:0100 correctly; two of those 83 are in the 153 all the same,
freed by the stack bound.

_EXTENDED_SIGNATURES also gained P2, P3, PL and PM, which is what refuses five
extender images that would otherwise load as their stubs. That one has no
before-and-after arm on this corpus -- those five load in none of the sweeps
retained here -- so it rests on the header bytes rather than on a count that moved.

The sweep was launched from an earlier revision of this branch. Its
cle/backends/mz.py differs from this one only inside _resolve_relative's
docstring, and the rest of cle/ is byte-identical, so the code the figures describe
is the code here.

Controls

Negative control, which is what makes is_default = True safe on a backend probed
ahead of PE: at angr/binaries master today, 106 tracked files begin with the MZ
magic and every one of them is a PE. All 106 still load, 0 are claimed by the new
backend, and none changes backend, mapped_base, min_addr or max_addr against
the baseline. Once the sibling pull request merges there are 108, and the two it adds
are claimed, which is what they are for. So that a zero means something, the same
harness claims the new fixtures: hello.exe as MZ with 5 relocations,
hello_tiny.exe as MZ with 0, and hello.com not at all.

The wrapped-segment resolution was checked for the value it returns, not only for
the file being accepted. Over all 1,571 loaded objects, the number whose resolved
entry or stack is not the unique reading inside the 20-bit space is 0 and 0.
The instrument is not blind: the same check against an earlier revision reports 17
objects whose stack resolved to 0x100000, one byte past the top of the declared
address space, and all 17 now resolve into the image. 89 objects carry a wrapped
e_cs or e_ss; a 16-bit disassembly at the resolved entry of 14 of them is real
code in every case -- thirteen a Borland-style segment fix-up sequence, one a jmp.

Fixtures

The three fixtures come from angr/binaries, in the sibling pull request this
description names, and are rebuilt byte-identically by the build script committed
beside them. Their own validation record is on that pull request.

@zardus

zardus commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

What cle does with the two DOS executables added in the fixture pull request,
before and after this change.

Before — every DOS MZ executable is refused, whatever is in it:

cle master 0e77ade
$ python -c "import cle; cle.Loader('binaries/tests/i386/dos/hello.exe', auto_load_libs=False)"
CLECompatibilityError: Unable to find a loader backend for
binaries/tests/i386/dos/hello.exe.  Perhaps try the 'blob' loader?

$ python -c "import cle; cle.Loader('binaries/tests/i386/dos/hello_tiny.exe', auto_load_libs=False)"
CLECompatibilityError: Unable to find a loader backend for
binaries/tests/i386/dos/hello_tiny.exe.  Perhaps try the 'blob' loader?

After — both load, in a 20-bit address space, with the entry point taken from
the header's CS:IP:

with this change
hello.exe
  backend             MZ
  arch                <Arch x86:LE:16:Real Mode (LE)>
  arch.bits           16
  mapped_address_bits 20
  os                  dos
  mapped_base         0x0
  min_addr/max_addr   0x0 / 0x2df
  entry               0x2b2  (CS:IP 0028:0032)
  initial_stack       0x280  (SS:SP 0008:0200)
  relocations         5
  segment             offset 0x40 vaddr 0x0 filesize 730 memsize 736
  CFGFast             16 nodes, 6 functions
  entry block:
    0x2b2: MOV  AX,0x0
    0x2b5: MOV  DS,AX
    0x2b7: MOV  DX,0x0
    0x2ba: CALL 0x2a0

hello_tiny.exe
  backend             MZ
  arch                <Arch x86:LE:16:Real Mode (LE)>
  arch.bits           16
  mapped_address_bits 20
  os                  dos
  mapped_base         0x0
  min_addr/max_addr   0x0 / 0xfeff
  entry               0x0  (CS:IP fff0:0100)
  initial_stack       0x0  (SS:SP fff0:0100)
  relocations         0
  segment             offset 0x20 vaddr 0x0 filesize 128 memsize 65280
  CFGFast             12 nodes, 5 functions
  entry block:
    0x0: MOV  DX,0x12d
    0x3: CALL 0x1b

hello_tiny.exe is the case a plain unsigned read gets wrong. 0xfff0 paragraphs
would put the entry at 0xfff00 + 0x100, a megabyte past a 160-byte file. DOS adds
it to the load segment in 16-bit arithmetic, so it names the paragraph sixteen
below the load module -- the Program Segment Prefix -- and the entry is offset 0
of the image, which is where the MOV DX above is. Its SS:SP is the same shape
and lands on the same byte: unwrapped it is exactly 0x100000, so a loader whose
upper bound includes the top of the real-mode space accepts both readings and has
nothing to choose between them.

After — the relocation table is applied, which is the part a blob load cannot
do. hello.exe loaded at 0x10000, so DOS's load segment is 0x1000 and every
relocated word gains it:

with this change
  site        in the file   in memory
  0000:0072   0x0028        0x1028
  0000:0076   0x0028        0x1028
  0028:0033   0x0000        0x1000
  0028:004a   0x0028        0x1028
  0028:004d   0x0028        0x1028

Without the fixups every one of those words still reads its file value, so every
far pointer and far call in the program points at the wrong segment.

@angr-bot

Copy link
Copy Markdown
Member

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

@ltfish

ltfish commented Sep 11, 2026

Copy link
Copy Markdown
Member

@rhelmot Do we want to add 16-bit support?

@zardus

zardus commented Sep 11, 2026

Copy link
Copy Markdown
Member Author

I would like to have 16-bit support for a personal project I'm doing!

@ltfish
ltfish merged commit ef79773 into master Sep 11, 2026
37 of 38 checks passed
@ltfish
ltfish deleted the feature/dos-mz-backend branch September 11, 2026 22:03
@ltfish

ltfish commented Sep 11, 2026

Copy link
Copy Markdown
Member

Merged. I guess loading 16-bit executables is not a big burden, but we do not plan to support 16-bit arches in angr proper.

@ltfish

ltfish commented Sep 11, 2026

Copy link
Copy Markdown
Member

Neither Yan nor I remember why we decided to not support 16-bit architectures in the first place, and after a longer discussion, I think we all believe it's time to abolish that rule, so we should reconsider supporting 16-bit architectures in angr.

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.

CLE crashes when trying to load FASM-compiled executables

3 participants