Skip to content

Commit 0c47ba8

Browse files
pgoodmanclaude
andcommitted
symex: structural Concat-fold for byte-shadow load/store round-trips
The byte-shadow store/load pipeline used z3.simplify to recover load- modify-store identity. z3 folds Extract(7, 0, X-Y) through byte arithmetic (the low byte has no incoming borrow), but leaves the upper Extracts unfolded because their borrow chain depends on the low byte. After a round trip, the value was Concat(Extract(31, 8, X), 212 + 255*b) — the parent identity for the low byte was destroyed and the full-Concat- collapse rule could no longer recover X. New _z3_concat_fold helper: flattens nested binary Concats z3 stores internally into a single MSB→LSB sequence, merges adjacent same-parent Extracts at contiguous bit ranges, collapses a full-width single Extract to its parent. Pure structural rewrite — never folds Extract through arithmetic. _shadow_write and _shadow_read now use the structural fold instead of z3.simplify. Multi-byte round trips recover the parent expression cleanly. Tests: new test_concat_fold.py covers the fold rules directly, the round-trip via _shadow_write + _shadow_read, partial-overwrite preserving minimal Concat, and an assertion that z3.simplify can't recover the parent (so the structural fold is load-bearing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e43859 commit 0c47ba8

2 files changed

Lines changed: 217 additions & 8 deletions

File tree

bindings/Python/symex/dispatch.py

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -305,13 +305,18 @@ def _shadow_write(shadow, addr, val, size):
305305
"""
306306
z3 = _z3_module()
307307
if z3 is not None and _is_z3(val):
308-
# Simplify before per-byte decomposition: when ``val`` was
309-
# constructed by re-concatenating bytes earlier read from the
310-
# shadow (the common round-trip), z3 collapses the
311-
# ``Extract(i+7, i, Concat(b_n, …, b_0))`` patterns back to the
312-
# original byte expressions. Without this, every load-modify-store
313-
# cycle accumulates Concat/Extract layers around the same bytes.
314-
val = z3.simplify(val)
308+
# Structural Concat-fold before per-byte decomposition: when
309+
# ``val`` was built by re-concatenating bytes earlier read from
310+
# the shadow (the load-modify-store round-trip), the fold
311+
# collapses ``Concat(Extract(31,24,X),Extract(23,16,X),...)``
312+
# back to ``X`` (or a single Extract) so the per-byte
313+
# decomposition below stores raw ``Extract(8i+7, 8i, X)``
314+
# entries. Using the structural fold here instead of
315+
# ``z3.simplify`` avoids the asymmetric-byte-arith collapse
316+
# that would otherwise produce ``212 + 255*b`` for the low
317+
# byte of ``1492 - zext_32(b)`` and lose the parent identity
318+
# for future loads.
319+
val = _z3_concat_fold(z3, val)
315320
i = 0
316321
while i < size:
317322
shadow[addr + i] = _z3_byte_at(val, i)
@@ -369,7 +374,13 @@ def _shadow_read(shadow, addr, size, data, buf):
369374
while j >= 0:
370375
result = z3.Concat(result, buf[j])
371376
j -= 1
372-
return z3.simplify(result)
377+
# Structural fold only — collapses consecutive Extracts of the
378+
# same parent back to the parent (or a single Extract). Avoids
379+
# ``z3.simplify`` here on purpose: simplify folds the low byte
380+
# through byte arith asymmetrically and prevents the parent from
381+
# being recovered. A consumer op (compare, branch, cast) is the
382+
# right place to run a full simplify.
383+
return _z3_concat_fold(z3, result)
373384

374385

375386
def _make_default_mem_read(is_float, shadow=None, buf=None, byte_order="little"):
@@ -569,6 +580,82 @@ def _z3_byte_at(val, i):
569580
return z3.Extract(8 * (i + 1) - 1, 8 * i, val)
570581

571582

583+
def _z3_concat_fold(z3, expr):
584+
"""Structural fold for Concat-of-consecutive-Extracts-of-same-parent.
585+
586+
Pure structure rewrite — never folds Extract through arithmetic.
587+
`z3.simplify` would do that asymmetrically (the low byte of a
588+
subtraction has no incoming borrow, so it folds into byte arith,
589+
while high bytes don't), producing shapes like
590+
`Concat(Extract(31, 8, X), 212 + 255*b)` where the low byte's
591+
parent identity has been lost and the round-trip back to X is
592+
impossible to recover.
593+
594+
Steps:
595+
596+
1. Flatten nested Concats — z3 stores n-ary Concat as a
597+
left-associated chain of binary applications, so
598+
`Concat(a, b, c, d)` is `Concat(Concat(Concat(a, b), c), d)`
599+
under the hood. The fold has to see it as a flat MSB→LSB
600+
sequence to merge consecutive Extracts.
601+
2. Merge consecutive Extracts of the same parent at adjacent
602+
bit ranges into a single Extract.
603+
3. If a single Extract spans the parent's full width, return
604+
the parent itself.
605+
606+
Rules 2 + 3 together recover the original parent expression on a
607+
full-width round trip: store a 32-bit value as four byte shadows,
608+
load all four, and the result is the original 32-bit term — no
609+
z3.simplify required, and (more importantly) no asymmetric
610+
arithmetic folding to the low byte.
611+
"""
612+
if not z3.is_app(expr) or expr.decl().kind() != z3.Z3_OP_CONCAT:
613+
return expr
614+
615+
# Step 1: flatten nested Concats into a single MSB→LSB list.
616+
parts = []
617+
stack = [expr]
618+
while stack:
619+
node = stack.pop()
620+
if z3.is_app(node) and node.decl().kind() == z3.Z3_OP_CONCAT:
621+
# Children are MSB→LSB; push in reverse so the pop order
622+
# walks them MSB-first.
623+
kids = node.children()
624+
for k in reversed(kids):
625+
stack.append(k)
626+
else:
627+
parts.append(node)
628+
629+
if len(parts) == 1:
630+
return parts[0]
631+
632+
# Step 2: left-to-right merge of adjacent same-parent Extracts.
633+
out = []
634+
for p in parts:
635+
if (out and
636+
z3.is_app(p) and p.decl().kind() == z3.Z3_OP_EXTRACT and
637+
z3.is_app(out[-1]) and
638+
out[-1].decl().kind() == z3.Z3_OP_EXTRACT and
639+
p.arg(0).eq(out[-1].arg(0))):
640+
top_hi, top_lo = out[-1].params()
641+
bot_hi, bot_lo = p.params()
642+
if top_lo == bot_hi + 1:
643+
out[-1] = z3.Extract(top_hi, bot_lo, p.arg(0))
644+
continue
645+
out.append(p)
646+
647+
if len(out) == 1:
648+
only = out[0]
649+
# Step 3: full-width Extract collapses to its parent.
650+
if z3.is_app(only) and only.decl().kind() == z3.Z3_OP_EXTRACT:
651+
hi, lo = only.params()
652+
parent = only.arg(0)
653+
if lo == 0 and hi == parent.size() - 1:
654+
return parent
655+
return only
656+
return z3.Concat(*out)
657+
658+
572659
# Per-arity dispatch tables: each entry is `(opcode_range, builder)`.
573660
# Builders for ops that need a z3 module function take (z3, a[, b]) so
574661
# the cached `_z3_module()` reference flows through; the rest are pure

tests/symex/test_concat_fold.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright (c) 2026-present, Trail of Bits, Inc.
2+
#
3+
# This source code is licensed in accordance with the terms specified in
4+
# the LICENSE file found in the root directory of this source tree.
5+
6+
"""Structural Concat-fold and shadow round-trip tests.
7+
8+
The shadow store/load pipeline must recover the original parent
9+
expression on a full-width round trip. `z3.simplify` can't do this —
10+
it folds the low byte through byte arithmetic asymmetrically (the low
11+
byte of `1492 - zext_32(b)` becomes `212 + 255*b`), which destroys
12+
the parent identity for the high bytes. The structural Concat-fold
13+
in `dispatch._z3_concat_fold` keeps the parent reachable.
14+
"""
15+
16+
import pytest
17+
18+
z3 = pytest.importorskip("z3")
19+
20+
from multiplier.symex.dispatch import (
21+
_z3_concat_fold, _shadow_write, _shadow_read, _z3_byte_at,
22+
)
23+
24+
25+
def _bv(val, w):
26+
return z3.BitVecVal(val, w)
27+
28+
29+
# ---------------------------------------------------------------------------
30+
# Direct fold tests: structural rewrites only.
31+
# ---------------------------------------------------------------------------
32+
33+
def test_concat_fold_full_width_round_trip():
34+
"""Concat of all four byte-Extracts of a 32-bit term collapses to
35+
the term itself (rule: full-coverage Extract → identity)."""
36+
b = z3.BitVec("b", 8)
37+
x = z3.BitVecVal(1492, 32) - z3.ZeroExt(24, b)
38+
parts = [z3.Extract(8 * (i + 1) - 1, 8 * i, x) for i in range(4)]
39+
# MSB-first concat (matching _shadow_read's layout).
40+
expr = z3.Concat(*reversed(parts))
41+
out = _z3_concat_fold(z3, expr)
42+
assert out.eq(x), f"expected parent recovery, got {out}"
43+
44+
45+
def test_concat_fold_partial_width_to_extract():
46+
"""Concat of two consecutive Extracts of the same parent collapses
47+
to one Extract spanning both."""
48+
x = z3.BitVec("x", 32)
49+
expr = z3.Concat(z3.Extract(15, 8, x), z3.Extract(7, 0, x))
50+
out = _z3_concat_fold(z3, expr)
51+
assert out.eq(z3.Extract(15, 0, x)), f"got {out}"
52+
53+
54+
def test_concat_fold_mixed_parents_stays_split():
55+
"""Different parents → fold leaves the Concat alone."""
56+
x = z3.BitVec("x", 32)
57+
y = z3.BitVec("y", 32)
58+
expr = z3.Concat(z3.Extract(31, 8, y), z3.Extract(7, 0, x))
59+
out = _z3_concat_fold(z3, expr)
60+
# Must be a Concat with two operands (not collapsed).
61+
assert z3.is_app(out) and out.decl().kind() == z3.Z3_OP_CONCAT
62+
assert out.num_args() == 2
63+
64+
65+
def test_concat_fold_simplify_does_NOT_recover_parent():
66+
"""Demonstrates the bug `_z3_concat_fold` exists to avoid: with
67+
`z3.simplify`, the low byte is folded through arithmetic and the
68+
full-Concat-collapse rule no longer matches."""
69+
b = z3.BitVec("b", 8)
70+
x = z3.BitVecVal(1492, 32) - z3.ZeroExt(24, b)
71+
parts = [z3.Extract(8 * (i + 1) - 1, 8 * i, x) for i in range(4)]
72+
expr = z3.Concat(*reversed(parts))
73+
# Naive z3.simplify: bytes 1-3 collapse, byte 0 turns into byte arith.
74+
naive = z3.simplify(expr)
75+
assert not naive.eq(x), \
76+
"z3.simplify unexpectedly recovered the parent — the asymmetric" \
77+
" byte-fold isn't firing in this z3 build, so this test no" \
78+
" longer guards what it was written for."
79+
# Our structural fold recovers the parent regardless.
80+
structural = _z3_concat_fold(z3, expr)
81+
assert structural.eq(x), f"structural fold lost the parent: {structural}"
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# Shadow round-trip: STORE + LOAD via the actual helpers.
86+
# ---------------------------------------------------------------------------
87+
88+
def test_shadow_round_trip_recovers_parent_32bit():
89+
"""Storing a 32-bit symbolic term then reading it back yields the
90+
same term, not a byte-split residue."""
91+
b = z3.BitVec("b", 8)
92+
x = z3.BitVecVal(1492, 32) - z3.ZeroExt(24, b)
93+
94+
shadow = {}
95+
_shadow_write(shadow, addr=0x100, val=x, size=4)
96+
# Concrete fallback bytes — _shadow_read prefers shadow entries
97+
# over these, so the actual values don't matter for this test.
98+
data = bytes(4)
99+
out = _shadow_read(shadow, addr=0x100, size=4, data=data, buf=[])
100+
assert out is not None
101+
assert out.eq(x), f"round trip lost parent identity: {out}"
102+
103+
104+
def test_shadow_partial_overwrite_keeps_minimal_concat():
105+
"""Overwriting byte 0 of a 4-byte symbolic term with a new
106+
8-bit value yields `Concat(Extract(31, 8, X), new_byte)` — the
107+
high bytes still reference X, the low byte is the new value, and
108+
no further fold collapses across the parent boundary."""
109+
b = z3.BitVec("b", 8)
110+
new_byte = z3.BitVec("new_byte", 8)
111+
x = z3.BitVecVal(1492, 32) - z3.ZeroExt(24, b)
112+
113+
shadow = {}
114+
_shadow_write(shadow, addr=0x100, val=x, size=4)
115+
# Now overwrite byte 0 with a fresh symbolic byte.
116+
_shadow_write(shadow, addr=0x100, val=new_byte, size=1)
117+
118+
data = bytes(4)
119+
out = _shadow_read(shadow, addr=0x100, size=4, data=data, buf=[])
120+
assert out is not None
121+
expected = z3.Concat(z3.Extract(31, 8, x), new_byte)
122+
assert out.eq(expected), f"got {out}, expected {expected}"

0 commit comments

Comments
 (0)