Skip to content

Add DOS MZ and COM test inputs for the cle loader - #232

Open
zardus wants to merge 1 commit into
masterfrom
feature/dos-mz-fixtures
Open

Add DOS MZ and COM test inputs for the cle loader#232
zardus wants to merge 1 commit into
masterfrom
feature/dos-mz-fixtures

Conversation

@zardus

@zardus zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Problem

cle cannot load an MS-DOS executable. It refuses one outright:

$ 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, and this
repository has no input a fix for it could be tested against. Before this change
106 tracked files start with the MZ magic; reading e_lfanew at offset 0x3c
from each and the four bytes it points at gives PE\0\0 106 times out of 106.
Every one is a PE image whose MZ header belongs to a stub, and 99 of the 106
declare no relocations at all, so none of them exercises the one thing a DOS
loader has to do.

Root cause

Nothing in the ecosystem has ever loaded a real-mode DOS program, so no DOS
fixture was needed.

Fix

Three fixtures, built from assembly written for this purpose, plus the sources and
the build script that produce them. Every header value below is read back out of
the committed bytes.

tests/i386/dos/hello.exe, 794 bytes, sha256
157b8cb060ae2ab393fa0b2d02edc321fca003a80bdb6023a529ffc6570af22b. An ordinary
real-mode MZ executable: a 64-byte header, a 730-byte load module, and an entry
point at 0028:0032 rather than at the start of the load module. The linker put it
in four segments -- data at 0000, a 512-byte stack at 0008, and two code
segments sharing frame 0028 -- which is a fact about the link map rather than
about the file, because MZ has no segment table and a loader sees one flat load
module.

e_cblp 0x011a  e_cp 0x0002  e_crlc 0x0005  e_cparhdr 0x0004
e_minalloc 0x0000  e_maxalloc 0xffff  e_ss 0x0008  e_sp 0x0200
e_ip 0x0032  e_cs 0x0028  e_lfarlc 0x0020  e_ovno 0x0000

Its five relocations are what make it worth having. The table at e_lfarlc holds
them as segment:offset pairs; the load-module offset each one resolves to, and
the word stored there, are:

0000:0072 -> 0x0072   0x0028   a far pointer held in _DATA
0000:0076 -> 0x0076   0x0028   a second far pointer held in _DATA
0028:0033 -> 0x02b3   0x0000   mov ax, DGROUP
0028:004a -> 0x02ca   0x0028   the segment word of a call far ptr
0028:004d -> 0x02cd   0x0028   mov ax, seg FAR_TEXT

A loader that applies them puts the image at some paragraph and adds it to each of
those five words; one that ignores the table leaves all five at their file values,
so every far call and far pointer in the program points at the wrong segment.

tests/i386/dos/hello_tiny.exe, 160 bytes, sha256
758b1946baba075466f137b9725d466124b8bcbadc28c94bd1f6dde1a98fef0b. The same
program in the .COM memory model, where the header's segment fields wrap:

e_cs 0xfff0  e_ip 0x0100  e_ss 0xfff0  e_sp 0x0100
e_cparhdr 0x0002  e_minalloc 0x0fe8  e_crlc 0x0000

DOS adds those to the run-time load segment in 16-bit arithmetic, so 0xfff0 means
the paragraph sixteen below the load module -- the Program Segment Prefix -- and
both the entry point and the top of the stack land on offset 0 of the image. Read
as plain unsigned paragraph counts they would be a megabyte past a 160-byte file.
e_sp is 0x0100 rather than something larger on purpose: 0xfff0 * 16 + 0x100 is
exactly 0x100000, so a loader whose upper bound is inclusive accepts both readings
and has nothing to choose between them.

tests/i386/dos/hello.com, 108 bytes, sha256
5bab2c65c03eff9853f3ca74ed52881e37a043873fa15e769fc1dac0ade358f5. A flat .COM
image: no header, no magic -- its first two bytes are ba 2d -- and org 0x100.
It is the negative control, a DOS program that a DOS executable loader must not
claim.

tests_src/dos/ carries dos_mz.asm, dos_mz_tiny.asm, dos_com.asm and
build.sh, which builds all three in place. Every tool comes from nixpkgs pinned
at 42f17a57f4f6e33b3de3dca0a2a5ea5233169d02: nasm for the .COM, Open Watcom's
wasm and wlink for hello.exe, and fasm for hello_tiny.exe, whose
format MZ writer is what turns entry code_seg-10h:start into e_cs = 0xfff0.
No header byte is written by hand. Open Watcom is unfree in nixpkgs under the
Sybase Open Watcom Public License, so that one step sets NIXPKGS_ALLOW_UNFREE=1;
the assembly is ours and links no Watcom runtime library.

Testing

build.sh was run from a clean extraction of the committed sources and rewrote all
three fixtures byte-identically. All three are real DOS programs: they print
through INT 21h, exit through INT 21h/4Ch, and run under DOSBox.

What a DOS MZ loader makes of each of the three, before and after, is in a comment
below.

Validation: #232 (comment)

session: sharpen

Before this change every file here that starts with the MZ magic is a PE image
whose MZ header belongs to a stub, so nothing in the repository exercises a
loader for MS-DOS executables, and 99 of those 106 stubs declare no relocations
at all.

tests/i386/dos/hello.exe is an ordinary real-mode MZ program: three content
segments plus a stack of its own, an entry point away from the start of the
load module, and five relocations -- two far pointers held in data, the segment
word of a far call, and two segment values loaded into a register.

tests/i386/dos/hello_tiny.exe is the same program in the .COM memory model,
where the header's CS and SS wrap below the load module to name the Program
Segment Prefix, and its SP puts the unwrapped reading of SS:SP exactly on the
1 MiB ceiling.

tests/i386/dos/hello.com is a flat .COM image with no header and no magic.

tests_src/dos carries the assembly for all three and a build script that
rebuilds them in place from a pinned nixpkgs revision, using nasm, Open Watcom
and fasm. No header field is written by hand. All three run under DOSBox.
@zardus

zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

Validation record for head c37a4352626f170dcb453974cc215a11b22bbb03 against baseline 0166109eb1fa0aec5baefb12403a487268f0e9ca.

The build script reproduces the committed bytes

The four files in tests_src/dos/ were extracted from this commit -- not from a
working tree -- into an empty directory with the tests/i386/dos/ output directory
beside them, and build.sh was run there:

$ ./tests_src/dos/build.sh          # exit status 0
5bab2c65c03eff9853f3ca74ed52881e37a043873fa15e769fc1dac0ade358f5  hello.com
157b8cb060ae2ab393fa0b2d02edc321fca003a80bdb6023a529ffc6570af22b  hello.exe
758b1946baba075466f137b9725d466124b8bcbadc28c94bd1f6dde1a98fef0b  hello_tiny.exe

All three match the committed files, and cmp against the bytes from the commit
exits 0 on each. Control, so that a passing cmp means something:
cmp hello.com hello.exe reports "differ: char 1, line 1" and exits 1. The output
directory ended up holding exactly the three fixtures; the Watcom object and link
map stay in a temporary directory the script removes.

Tools resolved from the pinned nixpkgs revision
42f17a57f4f6e33b3de3dca0a2a5ea5233169d02: nasm 3.02, Open Watcom Linker 1.9, flat
assembler 1.73.35.

A byte-for-byte match only means something if the build could have produced a
different answer, so a second arm was run from the same sources with one byte
changed inside a string literal in dos_mz_tiny.asm (goodbye. to goodbyf.) and
nothing else -- cmp -l between the two source trees prints exactly one line. That
arm moves hello_tiny.exe to
695d4df525e33b59761bb7f7ca7da5e4f7e1eeeb8617185827ca2a7e029c9c84 and leaves
hello.com and hello.exe where they were, which is the right answer in both
directions: the one file whose source changed moved, and the two whose sources did
not stay put.

All three are DOS programs that run

One headless DOSBox 0.74-3 invocation, all three in the same batch, each program's
output redirected inside the DOS shell and its exit status recorded with
if errorlevel 1. It was run once, against bytes identical to the ones committed
here:

hello.com        COM fixture: hello from real mode.                     exit 0
                 COM fixture: goodbye.
hello.exe        MZ fixture: hello from real mode.                      exit 0
                 far_hello ran in FAR_TEXT.
                 far_bye ran through a relocated far pointer.
hello_tiny.exe   Tiny-model MZ fixture: hello from real mode.           exit 0
                 Tiny-model MZ fixture: goodbye.

A 5-byte probe that does mov ax, 4C01h; int 21h ran in the same batch and was
reported as non-zero, so the three zeros are a measurement rather than a check that
always passes. The probe is not a fixture and is not committed. The three programs
print distinct messages, so none can have been mistaken for another.

hello_tiny.exe was copied in as TINY.EXE because its name is not 8.3; the bytes
were hashed after copying and are the committed ones.

hello_tiny.exe's stack sits in the PSP, and that was measured

It starts with SS:SP = PSP:0x100, so its stack grows down from the start of the
load module into the PSP. A probe program in the same shape, doing the same work,
painted PSP 0x60-0xFF and reported a low-water mark of PSP:00F8 -- 8 bytes
below the initial SP, two for a near-call return address and six for an interrupt
frame. The first PSP byte that would matter to DOS or to the program is the first
file control block at 0x5C, 164 bytes below the initial SP -- twenty times the
measured depth. DOSBox services INT 21h on the host, so real DOS would push more
than 8 bytes here.

Header fields, read back out of the committed bytes

                 hello.exe        hello_tiny.exe
e_cblp           0x011a           0x00a0
e_cp             0x0002           0x0001
e_crlc           0x0005           0x0000
e_cparhdr        0x0004           0x0002
e_minalloc       0x0000           0x0fe8
e_maxalloc       0xffff           0xffff
e_ss:e_sp        0008:0200        fff0:0100
e_cs:e_ip        0028:0032        fff0:0100
e_lfarlc         0x0020           0x001c
e_ovno           0x0000           0x0000
header / image   64 / 730         32 / 128

hello.com has no header: 108 bytes, org 0x100, first two bytes ba 2d.

The consumer

A DOS MZ loader backend for angr/cle is in preparation. Its tests/test_mz.py
opens all three fixtures. Run against a store build of that branch, with this
checkout of angr/binaries at the head above:

$ pytest --import-mode=append -q tests/test_mz.py
10 passed in 1.46s

On cle master 0e77ade3 that module does not import at all, because the backend
it names does not exist; loading each of the three fixtures directly on master
raises CLECompatibilityError: Unable to find a loader backend, with a tracked PE
loading normally in the same process as the control.

No workspace-gate result is claimed for this change: the diff is three binaries,
one shell script and three assembly sources, and no Python at all. The gate belongs
to the consumer.

What did not run

No hosted check is expected here. The check-runs API returns total_count: 0 at
the baseline commit and at the head commits of four closed pull requests in this
repository, angr/binaries #224, #193, #222 and #206. Positive control, so
that a run of zeros means something: angr/cle at 0e77ade3 returns 108.

Two workflows are registered. Build recompile-dataset (MSVC) cannot fire here: it
has no pull_request trigger, its file is not on master at all, and its push
path filter is tests_src/recompile_dataset/**, which this change does not touch.
Copilot code review can -- it produced four check runs on a head of
angr/binaries#163 in February 2026 -- so a maintainer who asks for a Copilot
review will see runs this record does not predict.

@zardus

zardus commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

THIS MESSAGE WAS GENERATED BY AN AUTOMATED PROCESS

What a consumer sees when it opens these three fixtures, before and after the
angr/cle DOS MZ loader backend these were built for.

Beforecle has no backend for any of them, so all three are refused:

cle master 0e77ade3
hello.exe       CLECompatibilityError: Unable to find a loader backend ... 'blob' loader?
hello_tiny.exe  CLECompatibilityError: Unable to find a loader backend ... 'blob' loader?
hello.com       CLECompatibilityError: Unable to find a loader backend ... 'blob' loader?

CONTROL  tests/i386/test_arrays.exe -> PE

The control is there so the three refusals mean something: the same cle build,
in the same process, still loads a tracked PE.

After — the two .EXE fixtures load and hello.com is still refused, which is
what it is here to check:

with the consumer change
hello.exe
  backend             MZ
  arch                <Arch x86:LE:16:Real Mode (LE)>   bits 16
  mapped_address_bits 20
  os                  dos
  mapped_base         0x0       max_addr 0x2df
  entry               0x2b2     (CS:IP 0028:0032)
  initial_stack       0x280     (SS:SP 0008:0200)
  relocations         5
  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)>   bits 16
  mapped_address_bits 20
  os                  dos
  mapped_base         0x0       max_addr 0xfeff
  entry               0x0       (CS:IP fff0:0100)
  initial_stack       0x0       (SS:SP fff0:0100)
  relocations         0
  entry block:
    0x0: MOV  DX,0x12d
    0x3: CALL 0x1b

hello.com        CLECompatibilityError: Unable to find a loader backend

Afterhello.exe's five relocations, applied. Loaded at 0x10000, so the
load segment is 0x1000 and every relocated word gains it:

with the consumer 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

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