xilinx: fix use-after-free when a LUT6_2 feeds a MUXF7/8/9 tree - #148
Merged
hansfbaier merged 3 commits intoAug 18, 2026
Merged
Conversation
pack_muxfs() runs before pack_luts() in both the xc7 and Ultrascale flows. Its constrain_muxf_tree() walks driver.cell across nets with no cell-type check, so a LUT6_2 feeding a MUXF7/8/9 input gets constr_parent/constr_children set on it by the mux-tree constraint walk. split_lut6_2() (previously called from inside pack_luts(), which runs afterwards) then deletes that exact cell via flush_cells() without repairing those pointers, leaving a dangling CellInfo* that the HEAP placer later dereferences -- segfault. Fix by running split_lut6_2() before pack_muxfs()/pack_carries()/ pack_srls() in both flows, so LUT6_2 no longer exists as a type by the time any pass that net-walks into driver cells without a type check gets to run. This closes the whole class rather than adding a type check to constrain_muxf_tree() alone. Also: - flush_cells() now asserts a cell being deleted has no live constr_parent/constr_children links, so a future instance of this class of bug fails loudly at the deletion site instead of segfaulting later, deep in the placer. - constrain_lut6_2_pairs() no longer overwrites constr_parent on a half that was already constrained by an earlier pass (e.g. its own mux-tree constraint) -- found via adversarial testing: the same LUT6_2 being both 5LUT/6LUT-pairable (constant I5) *and* driving a MUXF7 input hit the same class of dangling/desynced-constraint bug through a different code path (constrain_lut6_2_pairs unconditionally overwriting constr_parent), producing a different placer assertion failure. Such a pair is now left unpaired (costs an extra LUT bel, same tradeoff already accepted for the non-constant-I5 case) rather than corrupting constraint state. - split_lut6_2() now propagates the original cell's region constraint to both halves (a region is just a bbox, so this is safe and cheap insurance against silently dropping it). - split_lut6_2() fails loudly (log_error) instead of silently discarding state in two previously-unguarded cases: a LUT6_2 with a pre-existing BEL constraint (no defined rule for which physical half should inherit a single-bel pin), and a split-half name that collides with an existing cell (previously a bare NPNR_ASSERT deep in flush_cells()). Verified against 4 hand-built repro netlists: the original UAF (LUT6_2.O6 -> MUXF7), a paired constant-I5 LUT6_2 (still costs one physical LUT), a paired LUT6_2 whose O6 also drives a MUXF7 input, and the same with O5 driving the MUXF7 (the actual conflicting case for constrain_lut6_2_pairs -- confirmed by reverting the guard and seeing a different placer assertion fire). All four now place and route cleanly. Not fixed here (documented, left out of scope): - Non-constant-I5 LUT6_2 still costs 2 LUT bels instead of 1; the placer's fracturable-LUT model refuses a 5LUT companion whenever the 6LUT uses all 6 inputs (arch_place.cc), so closing this needs an arch-level change, not a packer-level one. - A BEL constraint on a pre-split LUT6_2 has no defined per-half splitting rule; no live producer of this was found in this codebase, so it now fails loudly rather than being silently guessed at. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
split_lut6_2() previously log_error'd on any LUT6_2 carrying a BEL attribute, regardless of whether O5 was actually used. That's a regression versus pre-split behaviour: before split_lut6_2() existed, a BEL-constrained LUT6_2 driving only O6 packed fine (it just aliased straight onto the LUT6 xform rule, keeping its single port name and BEL). Independent review caught this by diffing against the parent commit. A BEL names one physical resource; splitting is only genuinely ambiguous when both O5 and O6 are driven, since then there's no rule for which of the two resulting cells should inherit it. When only one output is used, exactly one half is ever created, so it can just take the BEL directly. Verified: a BEL-constrained, O6-only LUT6_2 now packs/places/routes cleanly (previously hard errored); a BEL-constrained LUT6_2 driving both O5 and O6 still errors, as intended. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jasonzeng124
force-pushed
the
fix/lut6-2-muxf-uaf
branch
from
August 14, 2026 17:41
0d9fda6 to
bfdeaf7
Compare
hansfbaier
requested changes
Aug 15, 2026
| // inherits it below -- but if both O5 and O6 are driven there's no | ||
| // defined rule for which half should get it. Fail loudly rather than | ||
| // guess in that case. | ||
| if (ci->attrs.count(id_BEL) && o5 != nullptr && o6 != nullptr) |
Collaborator
There was a problem hiding this comment.
Please extract the condition to a bool with a self documenting name
| std::unique_ptr<CellInfo> half = create_cell( | ||
| ctx, ctx->id("LUT" + std::to_string(n_in)), ctx->id(ci->name.str(ctx) + suffix)); | ||
| IdString half_name = ctx->id(ci->name.str(ctx) + suffix); | ||
| if (ctx->cells.count(half_name)) |
Collaborator
There was a problem hiding this comment.
Please extract the condition to a bool with a self documenting name
| // The BEL-ambiguity check above guarantees at most one half is | ||
| // ever actually built when the original cell was BEL-constrained, | ||
| // so it's safe to hand that BEL straight to whichever one it is. | ||
| if (ci->attrs.count(id_BEL)) |
Collaborator
There was a problem hiding this comment.
Please extract the condition to a bool with a self documenting name
| // pair unpaired rather than corrupt an existing constraint -- it | ||
| // costs the extra LUT bel split_lut6_2() already accepts for the | ||
| // non-constant-I5 case. | ||
| if (lut6->constr_parent != nullptr || lut6->constr_abs_z || !lut6->constr_children.empty() || |
Collaborator
There was a problem hiding this comment.
Please extract the condition to two bools with a self documenting names
Addresses review feedback on openXC7#148: name the BEL-ambiguity check, the split-half name-collision check, the BEL-inherit check, and the two already-constrained checks instead of inlining them at the call site. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Author
|
should be addressed... |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LUT6_2feeds aMUXF7/MUXF8/MUXF9input.pack_muxfs()'sconstrain_muxf_tree()walksdriver.cellacross nets with no type check and can setconstr_parent/constr_childrenon aLUT6_2beforesplit_lut6_2()(previously run insidepack_luts(), afterpack_muxfs()) deletes that exact cell — leaving other cells holding a danglingCellInfo*that the HEAP placer later dereferences.split_lut6_2()to run beforepack_muxfs()/pack_carries()/pack_srls()in both the xc7 and Ultrascale flows, soLUT6_2no longer exists as a type by the time any untyped driver-walk pass runs — this closes the bug class at its root rather than adding a type check to one call site.flush_cells()now asserts a cell being deleted has no liveconstr_parent/constr_children, so any future instance of this class fails loudly at the deletion site instead of segfaulting later, deep in the placer.constrain_lut6_2_pairs()no longer overwritesconstr_parenton a half already constrained by an earlier pass (e.g. its own mux-tree constraint) — found via adversarial testing on top of the reorder: aLUT6_2that is both constant-I5 pairable and drives aMUXF7input hit the same dangling/desynced-constraint problem through a different path. Such a pair is now left unpaired (costs one extra LUT bel — the same tradeoff already accepted for the non-constant-I5 case) instead of corrupting constraint state.split_lut6_2()now propagates the original cell'sregionconstraint to both halves (previously silently dropped).split_lut6_2()fails loudly (log_error) instead of silently misbehaving in two cases: a split-half name colliding with an existing cell (previously a bareNPNR_ASSERTdeep insideflush_cells()), and a BEL-constrainedLUT6_2that drives both O5 and O6 (genuinely ambiguous — no rule for which half should keep the BEL). A BEL-constrainedLUT6_2using only one output now correctly inherits the BEL onto that single half, matching pre-split behaviour (this was a regression caught by an independent review pass — see below).Scope
xilinx/pack.cc+xilinx/pack.honly. Two commits:6d4e5df1— the use-after-free fix (reorder, guards, region propagation,flush_cellsassert).0d9fda6a— follow-up fix for a regression an independent Opus review caught in commit 1: the BEL-constraint guard was over-broad and rejected a previously-working case (BEL-pinnedLUT6_2using only O6).Testing
Verified against hand-built repro netlists (chipdb: xc7z010clg400), each hitting a distinct code path:
LUT6_2.O6 -> MUXF7. Segfaults before this fix; places and routes cleanly after.LUT6_2with no mux involvement: confirms the pair still re-fuses onto one physical LUT (not two).LUT6_2(constant I5) with O6 also feeding aMUXF7: exercises the reordered flow's interaction with mux constraining.MUXF7: the actual conflicting case forconstrain_lut6_2_pairs()'s new guard — confirmed load-bearing by temporarily removing the guard and observing a different placer assertion fire (common/placer_heap.cc:1068).<name>$LUT5pre-existing): confirms the newlog_errorfires with a clear message instead of the old bare assert.LUT6_2driving only O6: previously hard-errored (regression introduced by commit 1's original, over-broad BEL guard); now packs/places/routes cleanly.LUT6_2driving both O5 and O6: confirms this still correctly errors (genuinely ambiguous case).All cases place and route cleanly except the two intentional
log_errorcases, which fail with the expected, specific message.Out of scope (documented, not fixed here)
LUT6_2still costs 2 LUT bels instead of 1 — the placer's fracturable-LUT legality check (arch_place.cc) refuses a 5LUT companion whenever the 6LUT genuinely uses all 6 inputs. Closing this needs an arch-level change, not a packer-level one, and doesn't affect correctness — a pre-existing, accepted limitation.LUT6_2whose O6 half single-fanout-drives aCARRY4input can have that half adopted directly into the carry chain bypack_carries()beforeconstrain_lut6_2_pairs()gets a chance to re-fuse the pair, permanently costing 2 LUT bels instead of 1. Not a correctness bug (no crash, no wrong output), just a LUT-utilization nit in a narrow corner case — deliberately left out of this PR.constrain_muxf_tree(): its own reentrancy guard (curr->type == id_SLICE_LUTX && ...) can never fire for a plain LUT, becauseSLICE_LUTXcells don't exist yet atpack_muxfs()time. A LUT with fanout into two different mux trees can therefore still get double-constrained. This is unchanged by, and unrelated to,LUT6_2— flagged by review for future work, not part of this PR.Review
An independent Opus-model review pass was run against commit 1 specifically. It found the BEL-guard regression above (fixed in commit 2) and confirmed the reordering itself is sound: no pass between
split_lut6_2()andpack_luts()deletes a LUT5/LUT6 half, andconstrain_lut6_2_pairs()'s guard is symmetric and can't leave a half half-fixed.