Skip to content

Commit 29b9972

Browse files
xusheng6claude
andcommitted
[emulator] Add known-answer tests that emulate compiled base64 and MD5
The existing suites test in the small: emulator_test.py assembles 3-13 byte x86-64 snippets and mostly exercises the API surface, and emulator_il_test.py checks one hand-built LLIL operation per assertion. Neither runs a long, arithmetic-dense trace, so a wrong bit in a carry, rotate or mask only shows up if someone thought to write that exact case. Add kat/kat.c with freestanding base64 and MD5 kernels, built for x86-64 and aarch64, and emulate them against the RFC 4648 and RFC 1321 vectors plus a differential comparison with hashlib across MD5's block boundaries. A published digest is a far better oracle than a hand-written assertion: one wrong bit anywhere changes it completely, and MD5's 64 rounds of rotate-and-wrapping-add exercise those paths unconditionally. The kernels are compiled -ffreestanding -nostdlib with builtins and autovectorization disabled, and take all buffers from the caller, so there is no process startup, no syscalls and no libc: what is under test is the emulator core rather than the libc stub layer. Compiling the same C per architecture also means one set of vectors validates each lifter/emulator pair -- which is how the aarch64 shift-count divergence documented in AArch64KATTests was found. The objects are checked in so the suite does not need a cross-compiler; rebuild them with kat/build.sh when kat.c changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 89be9f4 commit 29b9972

6 files changed

Lines changed: 407 additions & 0 deletions

File tree

plugins/emulator/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ out/
1515
*.pdb
1616
*.ilk
1717

18+
# The known-answer-test kernels are checked in deliberately, so the suite does not
19+
# need a cross-compiler. Rebuild them with test/kat/build.sh.
20+
!/test/kat/prebuilt/*.o
21+
1822
# Generated Python bindings
1923
/api/python/_emulatorcore.py
2024
/api/python/emulator_enums.py
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Known-answer tests for the BNIL emulator.
4+
#
5+
# emulator_test.py and emulator_il_test.py both test in the small: a handful of
6+
# instructions, one assertion each. These tests instead emulate whole compiled
7+
# algorithms -- base64 and MD5 -- against published test vectors.
8+
#
9+
# That matters because the two styles fail differently. A per-instruction test
10+
# only catches a bug you already thought to write a case for; MD5's 64 rounds of
11+
# rotate-and-wrapping-add catch carry, rotate, masking and truncation errors
12+
# unconditionally, because a single wrong bit anywhere changes the digest
13+
# completely.
14+
#
15+
# The kernels are built freestanding (see kat/build.sh) with no libc and no
16+
# syscalls, so each entry point is a pure function over caller-supplied buffers
17+
# and the emulator core is what is under test, not the libc stub layer. The same
18+
# C is compiled for every architecture below, so one set of vectors validates
19+
# each lifter/emulator pair.
20+
#
21+
# Run headless with, e.g.:
22+
#
23+
# PYTHONPATH=<bn>/python python3 -m pytest emulator_kat_test.py
24+
#
25+
# Requires the `emulator` core plugin to be present in the Binary Ninja install.
26+
27+
import hashlib
28+
import os
29+
import unittest
30+
31+
import binaryninja
32+
33+
try:
34+
from emulator import LLILEmulator, ILEmulatorStopReason
35+
except ImportError:
36+
from binaryninja.emulator import LLILEmulator, ILEmulatorStopReason
37+
38+
39+
KAT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'kat', 'prebuilt')
40+
41+
# Emulator address space. The loaded object's own sections sit at 0x400000; these
42+
# regions are placed well clear of it.
43+
STACK_BASE = 0x7000_0000
44+
STACK_SIZE = 0x10000
45+
IN_BUF = 0x1000_0000
46+
OUT_BUF = 0x2000_0000
47+
BUF_SIZE = 0x1000
48+
49+
MAX_INSTRUCTIONS = 20_000_000
50+
51+
52+
class KATBase:
53+
"""Emulates the freestanding KAT kernels for one architecture."""
54+
55+
obj_name = None # set by subclasses
56+
57+
@classmethod
58+
def setUpClass(cls):
59+
path = os.path.join(KAT_DIR, cls.obj_name)
60+
if not os.path.exists(path):
61+
raise unittest.SkipTest(f'missing KAT object {path}; run kat/build.sh')
62+
cls.bv = binaryninja.load(path)
63+
cls.bv.update_analysis_and_wait()
64+
65+
def emulator_at(self, symbol):
66+
"""Build an emulator positioned at ``symbol`` with a stack and buffers mapped."""
67+
funcs = self.bv.get_functions_by_name(symbol)
68+
self.assertTrue(funcs, f'{symbol} not found in {self.obj_name}')
69+
70+
emu = LLILEmulator(self.bv)
71+
72+
# The emulator has its own address space and does not read the view, so the
73+
# object's sections (crucially .rodata, holding the base64 alphabet and the
74+
# MD5 constants) must be copied in explicitly.
75+
for section in self.bv.sections.values():
76+
data = self.bv.read(section.start, section.length)
77+
if data:
78+
emu.map_memory(section.start, data, section.name)
79+
80+
emu.map_memory(STACK_BASE, STACK_SIZE, 'stack')
81+
emu.map_memory(IN_BUF, BUF_SIZE, 'input')
82+
emu.map_memory(OUT_BUF, BUF_SIZE, 'output')
83+
84+
self.assertTrue(emu.set_entry_point(funcs[0].start))
85+
emu.set_max_instructions(MAX_INSTRUCTIONS)
86+
# Leave headroom on both sides so a push or a red zone stays mapped.
87+
emu.set_register(self.bv.arch.stack_pointer, STACK_BASE + STACK_SIZE // 2)
88+
return emu
89+
90+
def run_to_halt(self, emu, what):
91+
emu.run()
92+
self.assertEqual(
93+
emu.stop_reason, ILEmulatorStopReason.ILEmulatorHalt,
94+
f'{what} did not run to completion on {self.obj_name}: '
95+
f'stop_reason={emu.stop_reason} msg={emu.stop_message!r} '
96+
f'at {emu.current_address:#x}')
97+
98+
# -- kernels ------------------------------------------------------------
99+
100+
def b64_encode(self, data: bytes) -> bytes:
101+
emu = self.emulator_at('b64_encode')
102+
emu.write_memory(IN_BUF, data)
103+
emu.set_arguments([IN_BUF, len(data), OUT_BUF])
104+
self.run_to_halt(emu, 'b64_encode')
105+
out = emu.read_memory(OUT_BUF, BUF_SIZE)
106+
return out[:out.index(b'\x00')]
107+
108+
def md5(self, data: bytes) -> bytes:
109+
emu = self.emulator_at('md5')
110+
emu.write_memory(IN_BUF, data)
111+
emu.set_arguments([IN_BUF, len(data), OUT_BUF])
112+
self.run_to_halt(emu, 'md5')
113+
return emu.read_memory(OUT_BUF, 16)
114+
115+
# -- tests --------------------------------------------------------------
116+
117+
# RFC 4648 section 10.
118+
B64_VECTORS = [
119+
(b'', b''),
120+
(b'f', b'Zg=='),
121+
(b'fo', b'Zm8='),
122+
(b'foo', b'Zm9v'),
123+
(b'foob', b'Zm9vYg=='),
124+
(b'fooba', b'Zm9vYmE='),
125+
(b'foobar', b'Zm9vYmFy'),
126+
]
127+
128+
def test_base64_rfc4648_vectors(self):
129+
for data, expected in self.B64_VECTORS:
130+
with self.subTest(input=data):
131+
self.assertEqual(self.b64_encode(data), expected)
132+
133+
def test_base64_matches_reference(self):
134+
import base64
135+
# Every residue of the 3-byte group, plus a payload spanning many groups.
136+
for n in (1, 2, 3, 4, 5, 16, 17, 18, 61):
137+
data = bytes((i * 7 + 11) & 0xff for i in range(n))
138+
with self.subTest(length=n):
139+
self.assertEqual(self.b64_encode(data), base64.b64encode(data))
140+
141+
# RFC 1321 appendix A.5.
142+
MD5_VECTORS = [
143+
(b'', 'd41d8cd98f00b204e9800998ecf8427e'),
144+
(b'a', '0cc175b9c0f1b6a831c399e269772661'),
145+
(b'abc', '900150983cd24fb0d6963f7d28e17f72'),
146+
(b'message digest', 'f96b697d7cb7938d525a2f31aaf161d0'),
147+
(b'abcdefghijklmnopqrstuvwxyz', 'c3fcd3d76192e4007dfb496cca67e13b'),
148+
(b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
149+
'd174ab98d277d9f5a5611c2c9f419d9f'),
150+
(b'1234567890' * 8, '57edf4a22be3c955ac49da2e2107b67a'),
151+
]
152+
153+
def test_md5_rfc1321_vectors(self):
154+
for data, expected in self.MD5_VECTORS:
155+
with self.subTest(input=data[:24]):
156+
self.assertEqual(self.md5(data).hex(), expected)
157+
158+
def test_md5_block_boundaries(self):
159+
# 55/56 and 63/64 are where MD5's padding spills into an extra block --
160+
# the cases most likely to expose a length or carry bug.
161+
for n in (54, 55, 56, 57, 63, 64, 65, 119, 120):
162+
data = bytes((i * 31 + 7) & 0xff for i in range(n))
163+
with self.subTest(length=n):
164+
self.assertEqual(self.md5(data).hex(), hashlib.md5(data).hexdigest())
165+
166+
167+
class X86_64KATTests(KATBase, unittest.TestCase):
168+
obj_name = 'kat-x86_64.o'
169+
170+
171+
class AArch64KATTests(KATBase, unittest.TestCase):
172+
obj_name = 'kat-aarch64.o'
173+
174+
# These fail on a known emulator/lifter disagreement about shift counts, not on
175+
# anything wrong with the vectors -- base64 passes here, and both algorithms pass
176+
# on x86-64.
177+
#
178+
# When the rotate amount comes from a table, the aarch64 lifter emits
179+
#
180+
# w9 = neg.d(w8) ; -7 -> 0xfffffff9
181+
# w8 = w0 << w8
182+
# w9 = w0 u>> w9 ; relies on the ISA masking the count to 5 bits
183+
# w0 = w9 | w8
184+
#
185+
# relying on ARM64 masking a register shift amount to the operand width. The
186+
# emulator shifts literally (see the LLIL_LSR/LLIL_LSL comment in
187+
# llilemulator.cpp), so the second shift yields 0 and every rotate collapses to
188+
# `x << c`. MD5 is 64 such rotates, so the digest is wrong for every input.
189+
#
190+
# Masking by operand width in the emulator would fix aarch64 but break 8/16-bit
191+
# x86 shifts, whose counts the x86 lifter has already masked to 5 bits and which
192+
# must yield 0 rather than wrapping -- so the resolution belongs in the aarch64
193+
# lifter, which is out of tree. These expectations flip to unexpected successes
194+
# once that is settled.
195+
# Defined as wrappers rather than
196+
# test_x = unittest.expectedFailure(KATBase.test_x)
197+
# because expectedFailure marks the function object itself, which the base class
198+
# shares with the x86-64 subclass.
199+
@unittest.expectedFailure
200+
def test_md5_rfc1321_vectors(self):
201+
KATBase.test_md5_rfc1321_vectors(self)
202+
203+
@unittest.expectedFailure
204+
def test_md5_block_boundaries(self):
205+
KATBase.test_md5_block_boundaries(self)
206+
207+
208+
if __name__ == '__main__':
209+
unittest.main()

plugins/emulator/test/kat/build.sh

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#!/bin/sh
2+
# Rebuild the known-answer-test objects consumed by emulator_kat_test.py.
3+
#
4+
# The checked-in objects under prebuilt/ are what the tests actually load, so that the
5+
# suite is hermetic and does not depend on a cross-compiler being installed. Run
6+
# this only when kat.c changes, and commit the regenerated objects.
7+
#
8+
# The flags are part of the test contract, not incidental:
9+
# -ffreestanding -nostdlib no libc is linked or assumed
10+
# -fno-builtin the compiler may not turn loops into memcpy/memset
11+
# -fno-tree-vectorize
12+
# -fno-slp-vectorize no SIMD, which would reach the emulator as an
13+
# LLIL_INTRINSIC and stop it without a hook
14+
# -O1 optimized enough to be interesting, not so much
15+
# that it reaches for vector or ISA extensions
16+
set -eu
17+
18+
cd "$(dirname "$0")"
19+
mkdir -p prebuilt
20+
21+
CFLAGS="-ffreestanding -nostdlib -fno-builtin -fno-tree-vectorize -fno-slp-vectorize -O1 -g0"
22+
23+
for target in x86_64-unknown-none-elf aarch64-unknown-none-elf; do
24+
arch="${target%%-*}"
25+
clang -target "$target" $CFLAGS -c kat.c -o "prebuilt/kat-$arch.o"
26+
echo "built prebuilt/kat-$arch.o"
27+
done

0 commit comments

Comments
 (0)