* Point the docs badge and gemspec at rubydoc.info/gems
Also drop the Inline docs (inch-ci) badge.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Pin the gemspec documentation_uri to the released version
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* Pin the gemspec changelog_uri to the release tag
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Every pre-generated entry is current again -- and a release is what
delivers it, since a remote BuildID lookup reads from the latest tag, so
until this one exists users still get the 2023-era gadgets.
Also stops telling people on the newest version to update it.
immidiate, substract, dumpped and BulidIDs, plus a @raise naming
Error::InstrutionArgumentError -- a class that does not exist, where the
real one is InstructionArgumentError, so that tag resolved to nothing.
Found by checking every word of prose against codespell's dictionary and
then against a full English wordlist; the remainder of what those flagged
is technical vocabulary or this codebase's British spellings.
* Rebuild the gadgets for the libcs the gem does not ship
805 of the 883 entries were regenerated from the libcs they were built
from. They dated from 2023 and predate v2.0.0 and v2.1.0, so they carried
gadgets that no longer work and missed ones that do: 10,326 recorded
gadgets became 41,417.
Nothing was added or deleted, and no shipped entry changed -- the packaged
gem is byte-identical and this reaches users only through a remote BuildID
lookup. The BuildID of every download was checked against the filename
before regenerating, so an entry can only be rewritten from the same binary
it named.
78 could not be rebuilt and keep their old content: 30 whose libc has since
disappeared from libcdb, 10 that record no usable source at all (libcdb has
no package matching their BuildID -- searched exhaustively by version), and
38 that were already current.
The architecture comment moves to elftools' present spelling, which is what
the one builds_info spec was pinning.
* Recover the last 40 from the distributions' own archives
libcdb no longer has 30 of the libcs it was the recorded source for, and 10
entries never named a source at all. Both distributions keep everything they
have ever published, so the libcs came from there instead: Launchpad for
Ubuntu, which still serves binaries removed from the pool years ago, and
snapshot.debian.org for Debian. All 40 were found, BuildID-matched as
before.
Several Debian ones matched the x32-hosted package rather than the
amd64-hosted one of the same version -- a different binary with a different
BuildID, which is why libcdb's copy did not match. The libc inside such a
package is an ordinary 64-bit or i386 one.
The 10 that recorded no source now record the archive URL they came from, so
they are re-derivable from here on, and the whole database is current: 883
of 883 entries, 43,410 gadgets.
A BuildID absent locally but present remotely was answered with "can be
found in lastest version, update with gem update one_gadget". That held
until 2.0.0, when the gem shipped every build; it now ships 34 of 883 by
design, so the advice reached almost every remote lookup and updating would
not have added them.
The lookup says what it is doing instead. It cannot go silent: nothing else
in that path prints, and a lookup that looks local is about to use the
network.
Two more architectures -- RISC-V and MIPS, both byte orders -- and a libc
that ships without section headers, which is how an embedded one usually
arrives. Every gadget the fixtures report for the new arches has been run
under a debugger and seen to spawn a shell.
Also: 67 gadgets a backward walk had been discarding, searches roughly
twice as fast on a stripped libc, and the gem's database no longer carries
glibc 2.26.
2.2.0 reads a symbol table without building an object per symbol, which is
the whole cost of reading a libc that ships without section headers. Such a
libc now searches in 0.24s against 0.44s, and mipsel musl in 0.39s against
0.69s; one with its sections intact is unchanged, since that path never
reads the dynamic symbols.
The spec suite goes 19.8s to 15.0s. Both measured interleaved, one version
then the other, because this machine's timings drift enough over a few
minutes to swamp the difference otherwise. The corpus is byte-identical at
levels 0, 1 and 2.
The entries had grown into explanations of how a thing works, where a
changelog reader wants what changed and what it is worth. Prose is down
about a quarter; the numbers a reader can act on -- 0.7s from 2.3s, 67 more
gadgets, 20 aarch64 gadgets recovered -- all stay.
Every #NNN is now a link. GitHub does not autolink those in a file blob, so
they were plain text on the page where the changelog is actually read.
Reference-style definitions keep them out of the way of the prose.
* Show what these do, rather than describing it
Four doc comments explained a method's behaviour in prose where its own
values say it in a line. Each example is a contrast where there is one --
the call with the load that aims it and the same call without -- since the
pair is what makes the rule visible.
Every value stated here was run: the arm mnemonics, the two mips windows,
the word posix_spawnattr_init opens with, and the objdump target list.
* Let the values say it
Seven doc comments explained behaviour in prose that examples say more
clearly: an illustration written out in words (+call __close+ then
+test eax, eax+), a subtlety about which offsets compare, what a candidate
with two terminal calls yields. Each is now a call and its answer, and
every value here was run before it was written down.
What no value can show stays prose: that the window constants must be a
multiple of four, and why the instruction set is settled up front rather
than learnt as lines arrive.
* Show the compares, the pointers, and the globals
Eight more doc comments where values say it better than the sentence did:
what each compare op records, what a relation's entry holds, which pointer
arguments carry a precondition a caller can arrange, which descriptors are
worth recording, what counts as a libc global.
The reasoning that no value can stand in for is what was cut -- why an ELF
may state one byte order and its instructions another, how a memo is
filled, why an unsigned carry holds over a range.
* Show the conditions, the swaps, and the modifiers
Four more where values carry it: which condition codes are aliases and
which have no constraint form at all, what xchg accepts against what it
refuses, how a modifier folds into an operand.
Two things checking the values turned up. xchg's example claimed
+xchg ebx,edi+ was a swap, which is only true on i386 -- on amd64 those
name narrower views and the instruction is refused, so the example
contradicted the sentence above it. And the refused forms are now a
@raise rather than prose. A shift folds only into a concrete value, which
the old wording did not say.
* Show the branch kinds and the GOT slots
Two more turned into values: which transfers count as branches, calls and
returns, and what a GOT slot holds on either side of DT_MIPS_LOCAL_GOTNO.
Two trims where no example was missing. prune_settled already carries four
examples covering every rule it has, so the sentence enumerating them said
nothing they do not; MASKED_ZERO is reasoning about what an attacker can
arrange, which no value demonstrates.
* Reunite a summary with its method, and three more
Helper.architecture's summary line had come adrift and was sitting on top
of MACHINES, describing the constant as though it fetched something.
symbolize's second paragraph becomes the contrast it was describing: a
destination is rewritten and named, an operand that merely looks like one
is not. i386's argument doc drops the running commentary it was written
as.
* Show what an arithmetic result is named
An offset from a known base stays one; a sum of two unknowns is named as
the operation. Two lines of values in place of the paragraph saying so.
x86's candidates keeps its reasoning -- that the rule cannot move to Base
because aarch64 builds a string address in pieces is a measurement, and no
value stands in for it -- but says it in one sentence.
* Give two examples the values they were missing
get_corresponding_stack's examples were bare calls with nothing coming
back, which showed only that the method can be called. narrowed said in a
sentence which part of a value a narrower name addresses; two calls say it.
Both had examples already, so neither showed up as verbose -- the prose
was doing work the examples beside it should have been.
disasm_lines holds a window per terminal call rather than a whole file, so
the line before a window's first is the last of another, somewhere else
entirely. The search for the ldr/add pair that establishes a GOT base
walked back 400 lines by index regardless, and could take its answer from
a function the path never reaches.
That is the second way a shorter window disagreed with a full disassembly:
arm-2.27 reported an execve gadget at 0x8915c whose GOT base was seeded
from code 0x11000 bytes away. At back=0x360 the arm fixtures now agree with
a full disassembly exactly, where 73 of 254 lookups previously answered
from past a window edge.
Nothing changes at the shipped window, and not by luck until now: 400 lines
cannot span 0x1000 bytes, so the search could never reach an edge. The
corpus is byte-identical at levels 0, 1 and 2.
Every one of these is a LOAD segment, and LoadSegment already answers for
what they were taking out of the header: mem_head, file_head, and mem_tail
for the p_vaddr + p_memsz the harness was adding up itself.
The gadget corpus is byte-identical at levels 0, 1 and 2, and the address
the harness steers writes to is unchanged for every fixture.
Left alone: helper.rb reads e_machine, for which there is no accessor --
ELFFile#machine answers with a display string ("Advanced Micro Devices
X86-64 processor"), which is no basis for choosing a fetcher.
Running out of PATH_BUDGET returned without emitting, where running out of
predecessors emits the path walked so far. A path long enough to hit the cap
contributed nothing at all, so which gadgets a libc yields depended on how
far back it had been disassembled -- which is what made a shorter window
look like it was inventing them.
67 more gadgets across the fixtures, none lost. Verified strict:
aarch64-2.43 148 PASS (was 143), MIPS 123 PASS (was 61).
Six of the recovered ones did not work, all opening at a bal. o32 has the
callee build its own gp from t9, so a caller must leave the callee's address
there; a window that skips the load establishing it calls a function that
then finds nothing. MIPS now refuses those, reading the callee's prologue to
tell whether it needs t9 at all -- posix_spawnattr_setflags does not, and
that gadget verifies, so it is kept.
Reading a symbol through its header is what forces a structure to be built
for it, which is most of the cost of reading a symbol table: on libc-2.31
that is 203 objects per symbol, and one_gadget reads every symbol of a libc
that has been stripped of its sections.
The accessors say the same thing today and, with the lazy read pending in
elftools, say it without building anything -- the spec suite goes 17.0s to
12.6s once that lands, against 16.3s for the same elftools reached through
the header.
The window either side of a call was 0x4000 in each direction, chosen as
16x a threshold measured only to the nearest 0x400. Sweeping every fixture
against its own full disassembly puts the real floor at 0x368 back and
0x350 forward, so 0x1000 keeps about four times what the corpus asks for.
Every fixture reports the same level 2 gadgets as a full disassembly, and
the comment now records the two ways a shorter window goes wrong: it must
be a multiple of four, and it can invent gadgets as well as lose them.
The call scan reaches every instruction in a libc, and all but a handful
are not calls at all. arm decoded a halfword's neighbour before asking
whether the halfword could open either encoding; MIPS handed every word
to a decoder that had to work out the answer was no.
The MIPS GOT slot lookup is kept too: the same slot is reached from every
call site that goes through it, and reading one allocates.
The disassembly of a file is already kept for the lifetime of the process,
for the reason its own comment gives: the same file gets analysed again and
again. What is derived from it was not, so every repeat search walked the
candidates and emulated them from scratch.
Gadgets are handed out as copies, since a caller may write to one.
Profiling the suite found two answers recomputed rather than kept: which
targets an objdump binary supports, asked with a subprocess for every
command a search assembles (307 of them across the specs), and the regexp
naming a call mnemonic, rebuilt from a constant string for every
disassembled line.
MIPS had no cheap call finder, so every search disassembled the entire
file. One word per instruction, word-aligned, makes it a masked compare:
a direct call carries its destination, and the GOT load PIC code calls
through names the slot it takes one from.
Gadget output is byte-identical at level 100 for all three fixtures.
* Verify MIPS gadgets with Aletheia
* Record Finding 13 and let the verify task name mips
* Arrange a GOT base a gadget restores from its frame
* Write memory in the target's byte order
* Read a MIPS disassembly the way the engine expects
* Let elftools state the dynamic tag numbers
* Name a call only when the GOT load is what set its target
* Let the load segment do its own address arithmetic
* Delay a transfer of control instead of reordering the disassembly
#436 gave every public method a @param for each parameter and a @return.
Nothing kept it that way: a new architecture arrives with a dozen inst_*
overrides, and the next one to land bare would land unnoticed.
doc:untagged reads the same registry `doc` builds and names what a method
leaves unsaid. Two things it deliberately does not report: an attribute,
which is documented once by the @return on its reader, and a block, which
@yield tags describe. A keyword or splat parameter is named as a @param
names it, without the colon or the stars, or every method taking **options
would read as undocumented.
YARD supplies some of these itself -- a @return [Boolean] for a predicate,
a constructor's return -- and the check takes them as given rather than
asking for what a reader already sees.
Nothing in lib now carries more than three paragraphs of prose.
dispatch_safe_call spent 31 lines defining SafeCalls' requirement
vocabulary -- what :deref and :nullable_deref and the rest mean. That is
what SafeCalls::COMMON records, so it is documented there, and the method
says what it does with an entry. Three @example tags in it were nested
inside bullets, where YARD does not read them as tags at all: it parsed
zero examples and rendered them as literal "@example ..." text. They are
prose now, which is what they always were.
calculate_score carried the grammar of the constraint language on a
private scorer. The class emits those strings, so the class states the
grammar.
prune_settled enumerated four rules and then demonstrated all four in
@examples. The examples stay and the enumeration goes.
Conditional described the one-line look-ahead that resolve_pending_branch
documents in full, and WINDOW_BACK explained the forward reach that
WINDOW_FWD states in one line, plus a fallback stated where it happens.
clobber_caller_saved's TODO -- that an accepted call is assumed to succeed
rather than required to -- is a design proposal, not a description. It is
in the work queue.
Every public method now states its parameters and its return: 18 were
missing a @param and 22 a @return, including every arch's process!
override, RegisterFile's Hash accessors, Base.cached and the branch
registrations in Conditional.
Thirteen private methods gained a line, the ones whose name does not say
what they do -- take_until, which keeps ties and so may return more than it
is asked for; X86#to_lambda, which answers a vector register with one value
per lane; the Thumb BL encoding helpers, whose offset is scattered across
both halfwords; PIC's distance from the GOT to "/bin/sh"; and the argv
builders, where "with sh" means libc has already filled in argv[0]. The
rest -- inst_mov, emulator, call_str and their kind -- are left bare, since
their names already say it.
Descriptions are left alone. A method whose name tells may still carry a
one-line summary, and the ones here do; nothing in lib carries more than a
line without earning it.
The document ran 1-6 then jumped to 11. Finding 8 is the one that was
missing: argv built in place through a pointer no register names, so the
stores went untracked and argv rendered as a NULL disjunct the gadget's own
store to argv[0] overwrites.
It also records why the fix took the shape it did. Finding 6 proposed a
write history keyed by register name and named invalidation as the hard
part; keying by the value the base renders as dissolves that, since a
reassigned register names a different value and nothing goes stale. #378
later measured the guard Finding 6 worried about and found it only ever
discarded history that was still good.
Finding 6 said "not yet fixed -- pending review", which stopped being true
when that landed: all three of its gadgets now name the register it was
written to expose. Its status says so, and the section is kept as written
because its reasoning is what the fix was measured against.
Findings 7, 9 and 10 stay out, and the introduction now says why: they were
bugs in the harness, not in one_gadget's output.
* Add an unknown value to a known one without raising
`registers[dst] += value` asks the register to add the operand to itself.
A Lambda only knows how to add a number, so a register holding a number
and an operand that is not one raised TypeError -- not one of the errors
that drop a candidate, but a crash out of the whole search.
Nothing reaches it today: amd64's candidate filter rejects those windows
before they are emulated, which is how it stayed hidden. Removing that
filter reproduces it immediately.
Addition commutes, so the two are swapped and the result is the unknown
value shifted, which is the rule DataProcessing#offset_result already
states for the architectures that go through it. Corpus byte-identical.
* State the x86 candidate filter once
amd64 and i386 each carried their own rule for which candidates are worth
emulating, and only amd64's admitted a terminal posix_spawn -- a window
reaching one need not mention the string, since the path is an argument.
i386's rule would have dropped such a candidate before the emulator saw
it. Nothing in the corpus is affected today, but nothing was making it
safe either.
Both are the same rule over a number each names differently, so X86 states
it and i386 says only that PIC holds the distance from the GOT. amd64's
extra "the last line calls execve" clause is gone: it drops nothing, and
removing the whole filter leaves the corpus byte-identical.
Measured over the 22 fixtures at levels 0/1/2, output identical in every
case: filters as they were 8.5-8.9s, i386's removed 9.3s, amd64's removed
9.9s, both removed 10.7s, one shared rule 8.9s. The filter is worth ~25%,
which is why it stays. It cannot move to Base: an architecture that builds
the address in pieces (adrp then add) never names it, and filtering
aarch64 on the same test drops all but one of its gadgets.
* Record the two user-visible fixes in the changelog
* Read a libc that ships without section headers
An embedded libc commonly has them removed -- OpenWrt strips musl that way to
save flash, and a vendor firmware may do the same to glibc. Nothing is missing
from such a file, since the loader still has to find the symbols, but the route
one_gadget takes to them is: objdump disassembles sections, so a file with none
disassembles to nothing at all and no gadget is ever found.
Three answers, none of which the architectures see:
- symbols come from PT_DYNAMIC, counted by DT_HASH's chain (or by where the
string table starts, for a file carrying only the GNU hash).
- objdump reads the bytes rather than the ELF: -D -b binary, told the machine,
the endianness, and what the first byte is loaded at.
- that raw disassembly is rewritten to look like the ordinary kind, naming a
target from the recovered symbols. So the engine recognises a terminal call,
and reads a branch target, from exactly what it always reads.
The one thing that had to change elsewhere was a filter looking for
"posix_spawn@" -- the @ is glibc's symbol versioning, which musl does not have
and a recovered name does not carry. It now matches the name however it is
written.
Proven by stripping a fixture at spec time and requiring it to report the same
gadgets: 4/32/96 at levels 0/1/2, identical lists. The rest of the corpus is
byte-identical.
* Scan the code segment, and let elftools say which it is
.text and the executable segment name the same code, so the section
branch only ever ran first. Reading the segment alone finds the same 426
call sites across the corpus and leaves every gadget byte-identical, and
it is the one that also answers for a file with no section headers.
segments_by_type(:load) and Segment#executable? say the same thing as the
hand-rolled p_type/p_flags tests, here and in file_offset.
* Keep the terminal posix_spawn pattern with the other terminal names
How the symbol is spelled is a property of the name, not of an
architecture, so it belongs beside TERMINAL_PREFIXES rather than in the
one fetcher that happens to match on it.
* Strip the fixture's sections through elftools
Assigning the header fields records a patch that save applies, so the
offsets and widths come from the ELF being read rather than from
constants that only describe a 64-bit little-endian file.
* Unit-test the dynamic symbol reader, and move the gadget check to amd64
spec/fetchers/ drives a fetcher's own methods; the end-to-end "a stripped
libc reports the same gadgets" check is a gadget-level assertion about an
amd64 fixture, so it belongs with the other ones.
What is left behind now calls the module: the dynamic-segment route has to
agree with the symbol table, symbol_count's two ways of sizing the table
have to agree (the file carries DT_HASH, so the GNU-hash-only branch was
never taken before), file_offset answers nil off the end, and symbolize
rewrites the three shapes of line it is given.
* Ask the header how many sections there are
num_sections reads e_shnum; sections builds a Section object for each one
only to count them.
* Read the dynamic symbols with elftools
elftools 2.0.0 reads a symbol table through the tags itself, hash tables
and all, so the table walk here was a second implementation of it: 62
lines for symbol_count, read_symbols, each_symbol_entry, dynamic_tag and
file_offset (which offset_from_vma already answers).
It also unpacked entries with 'V*', which is little-endian only and would
have read a big-endian libc as garbage; the structures elftools reads are
declared big_and_little and take their width from the ELF class.
Identical symbol maps on all 22 fixtures, and identical terminal
addresses. It is slower -- the count comes from the relocations as well as
the hash tables -- which costs about 0.6s on a stripped libc, against the
15s that searching one takes.
* Compare the stripped libc's gadgets once
Nothing is trimmed above RAW_LEVEL and every lower level is a function of
that set, so one comparison there covers all of them.
* Set up raw disassembly once, not per query
The setup ran ahead of the memo, so every one of the 10537 calls that ask
for the disassembly re-opened the file and re-derived the objdump command.
A stripped libc took 16.0s to search; it now takes 0.8s, for the same 184
gadgets.
A doc block written above a definition follows whatever text sits below
it: write a new method between the two and YARD hands the fused pair to
that method, dropping the original's description without a word. Two such
blocks were already in the tree, and the one that turned up in review was
caught only because both halves happened to name the same @param.
rake now runs doc (with --fail-on-warning, so an unresolvable link or a
duplicate tag is an error) and doc:orphans, which reads every commented
file for a summary line following a tag -- the shape a fused block always
has, and the one YARD is silent about.
The database is generated from the Ubuntu LTS libcs and the spec fixtures, and
riscv64 was in neither: every other fixture had an entry, its own did not, so the
next refresh would have produced an unexplained diff. It now has all four --
focal 2.31 (182 gadgets), jammy 2.35 (139), noble 2.39 (171), and the fixture --
taking shipped_builds to 37.
riscv64 lives on the same ports mirror as arm64 and armhf, so the LTS side is one
entry. What actually kept it out was a second list: the generator carried its own
%i[aarch64 amd64 arm i386] of what can be searched, which adding an architecture
does not update. It asks OneGadget::Fetchers now, which is where that set really
lives, so the next architecture cannot be silently left out the same way.
Refreshing also fell over on the first riscv64 libc: the early return for an
unsupported architecture happens before the file is opened, and the ensure closed
it regardless. Reached only from that return, which nothing had taken before.
Verified -b == -f for the new fixture entry at levels 0/1/2 (4/38/151 gadgets
either way), and builds:audit passes.
An sp-relative slot needs nothing written to be NULL -- the stack lands in the
zero-filled scratch -- and apply_null answered "already zero" on that ground
alone. It holds only while nothing else has put something there. Another
constraint pointing that very slot at scratch leaves the two contradicting each
other, with the write landing at a fixed low address on an unmapped page. It now
looks before answering.
That is the general form of the hazard #427 patched for one shape, so the
special case goes: an indexed writable no longer has to nominate its base as
null-unsafe, because a later NULL on it simply fails and another disjunct is
taken. Every plan in the corpus is byte-identical to before, branches, registers
and memory alike -- the same answers, reached by a rule rather than by a list.
deref_reads_zero? also called .zero? on whatever memory held, which raises on a
stored scratch pointer. Nothing in the corpus reaches it today; re-checking a
finished plan does, immediately.
The end-of-plan re-check this came from is NOT here, and should not be built on
satisfied? as it stands: it disagrees with apply for 1265 of the corpus's 5930
chosen disjuncts -- array literals, sp-relative NULLs and the indexed writable
among them -- so every one would be a false alarm. Bringing the two into
agreement is the real prerequisite, and a bigger change than the check.
* State what a gadget writes into the array it passes
riscv64-2.39 0xb5718 and 0xb571e failed strict verification and passed benign,
the signature of a constraint that is missing. Both windows load the envp base
[sp+0xd0], add a scaled index, and store s8 there before handing that array to
posix_spawn -- while the constraint list still described the array as though the
gadget had not written to it. Under poison s8 is an unmapped pointer, execve
fails EFAULT, and no shell runs; pinning s8 to NULL turns the run into a PASS.
The value was never lost, which is why nothing had noticed: resolve_address keys
such a write under a base named ((a5 << 0x3) + [sp+0xd0]), and nothing reads that
back. So a store whose address is an operation is recorded as one, and the
argv/envp resolver asks what was written through a base built from the array
pointer -- exactly where its "TODO: handle the case when libc will write
something into envp" sat.
Operands are matched structurally rather than by text: a substring of a rendering
finds x3 inside x30, which put constraints on unrelated aarch64 gadgets.
Three gadgets gain "s8 == NULL || readable: s8" -- 0xb571c had the same store and
was passing on a lucky witness. Nothing else in the corpus changes at any level,
and riscv64 level 2 is now 171 PASS, 0 FAIL, 0 SKIP.
* Put back the doc comment the last commit split
The three new methods landed between get_corresponding_stack's @param and its
@return, so its description, examples and parameter documented derived_writes
instead, and get_corresponding_stack was left with a lone @return.
Its comment is whole again. writes_through, the one of the three a fetcher
calls, stays public where it was; derived_writes and operands_of are internal to
the write tracking and move below private, beside track_write which fills them.
* Plan the constraints that left 30 gadgets unverified
riscv64's posix_spawn environ loop was the only place in the corpus stating a
writable target built by indexing -- ((i << 3) + base), an element address with
both halves supplied by the caller -- and the satisfier could not parse that
shape at all, so 30 level-2 gadgets went untested. Emptying the index leaves the
address wherever the base points, which is an ordinary writable target, and that
is what it now arranges.
Clearing that uncovered four more, three of them nothing to do with this shape:
- a base another constraint had already pointed at scratch was arranged again,
and the second slot refused as a conflict; ask first whether it is already
writable.
- the witness for `X != -1` was 0, which is what every unpinned register and
every untouched scratch word already reads as -- the one value certain to
collide with anything else comparing X. Pick the smallest that is neither zero
nor excluded.
- an inequality was checked against the witness picked for it rather than
against the relation, so it rewrote locations that already satisfied it, and
that write is what the next constraint collided with.
- a register/memory inequality tried to repin a register another constraint had
already settled, which can only fail; arrange the memory side instead.
- a NULL chain rooted in a libc global ([[[$base+0x171fc0]]] == 0x0) had nowhere
to start, the walker knowing only stack- and register-rooted chains. The global
holds the first pointer, written by the driver once the load base is known.
An index a sibling constraint has already settled is also accepted now, provided
its element still lands inside the base's writable area: a gadget writing both
base[i] and base[i+1] states two such addresses, and the second cannot empty an
index the first chose.
Every fixture now plans every gadget: 0 skips at level 2, across all five
architectures, down from 30.
* Keep a steered base pointed where the write needs it
An indexed writable points its base at scratch, and a later constraint choosing
NULL for that same base puts the write at a fixed low address instead -- on an
unmapped page, not a merely untracked one. That is the hazard the compound
writable already guards against, so the base joins the same list once it has
been steered.
Seen on riscv64-2.39 0xb5718, where the plan arranged writable: ((a5 << 0x3) +
[sp+0xd0]) and then answered the envp constraint with [sp+0xd0] == NULL.