B17 through B30, fourteen items. Every one reproduced before it was touched
and mutation-proven after. 194 tests from 119, rubocop 903 offenses to 0,
all six gate stages green.
One rule runs through all of it: nothing may present a guess, a default, or
a swallowed error as a verdict.
Decision states (B19). Under POLICY_OBSERVE_AND_LOG a payload carrying a live
Gem::Requirement#marshal_load snapshot reported accepted? true AND rejected?
false, so both obvious caller shapes loaded it. The predicate pair could not
express the third outcome, so there was no safe branch to pick - the prior
note claiming target/app.rb sidestepped this by branching on rejected? was
wrong, and both forms were byte-for-byte equivalent in outcome. accepted?,
rejected? and would_reject? are removed rather than redefined, so copying
`if d.accepted?` now raises NoMethodError instead of silently changing
meaning. One state validated in the constructor, three exclusive predicates,
and proceed? is the only one that may gate a Marshal.load. Observe-and-log
stays non-blocking and still hands back its snapshot; the monitoring caller
writes `proceed? || observed?` and names the state out loud.
Scanner error accounting (B18). Five rescues returned nil, [] or false and
told nobody. They now record site, subject and error class, and Report
exposes suppressed_count, suppressions_by_site, complete? and
candidates_lost?. Wiring the counter immediately surfaced 3 suppressions on
a stock image that had always been invisible: <internal:symbol>,
<internal:pathname_builtin> and <internal:ractor> all fail Prism.parse_file
with ENOENT because Ruby hands out those paths but they are not files.
The fourth state (B29). Those 3 suppressions were also 7 wrong answers -
candidates scored "does not touch state", indistinguishable from analysed
and inert, and 4 zero-arity ungated ones silently dropped from reachable.
touches_state is now four-state. A source that was given and could not be
parsed fails OPEN and stays reachable, because a scanner that discards what
it failed to analyse is the exact failure mode B18 names. A C-defined method
with no Ruby source at all is reported as unanalysable instead: 132 of 173
candidates, and failing open there would take reachable from 25 to 74 of 165
ungated and stop the filter filtering. Report#unanalysable and
#fully_analysed? state the real coverage - 33 of 173 - rather than implying
the filter saw everything. The 5 recovered candidates were verified by
executing them, not by reading source we could not read; one of them,
ERB::Compiler::PercentLine#to_s, is an alias of an attr_reader, which is a
second and distinct analysis gap.
Reason escaping (B20). Reject reasons interpolated raw attacker bytes into a
caller-supplied reporter. A class name carrying CR, LF, ESC and NUL turned
one reporter call into three log lines, the middle one forging a successful
authentication. All three interpolation sites now truncate at 96 bytes and
inspect the binary form, so no byte below 0x20 survives and the value is
quote-delimited.
Target hardening (B21, B22). The defended endpoint returned HTTP 500 with a
source line for three roots the detector had just accepted, leaking paths the
same way the B3 mutant did. show_exceptions is off, the shape is checked, and
the gate now greps every error body for source paths. ALLOWED_CLASSES could
never match anything, measured: a benign session cookie carries zero class
names. It is PERMITTED_CLASS_NAMES = [] now, which is what the app actually
requires and is strictly tighter - the old list admitted a C-wrapped String,
and under a mutant restoring it only the new shape check stopped that payload.
Gated agreement (B23). GATED_SINK_TAGS omitted TAG_DATA while the scanner
listed _load_data. The scanner was right, and this is now execution evidence
rather than a reading of marshal.c: a hand-built d stream naming Thread::Mutex,
a real C-level T_DATA, raises TypeError naming the missing _load_data, while
the same stream naming String dies earlier at "dump format error" - which is
why the previous attempt could not see it. A test compares both definitions
directly so they cannot drift again.
Float fidelity (B17). read_float returned nil for seven body forms
Marshal.load accepts, two more than the finding listed. Ruby uses its own
ruby_strtod, so "INF" is 0.0 while "inf" is Infinity, and String#to_f turns
out to be that same function. Ruby's legacy binary mantissa is NOT decoded:
a model fitted to four oracle points passed a 25-case table and then failed
930 of 5000 randomised cases, and marshal.c is not available in these images.
Since 0 of 209 Marshal.dump outputs contain a NUL, no living Ruby emits that
form, so the parser records the strtod prefix and flags Node#undecoded_tail
instead. A plausible wrong number is worse than a labelled non-answer.
Final differential: 2919 agreed exactly, 2081 flagged, 0 claimed-and-wrong.
Hygiene (B24, B25, B27, B28). PRIMITIVE_CLASS_NAMES and NAMESPACE_SEPARATOR
had one reference each, their own definition. BIGNUM_SIGN_POSITIVE is live
and stays. The width > FIXNUM_MAX_WIDTH guard is unreachable for all 256
possible marker bytes, checked exhaustively, and raised the wrong error class;
it is replaced by a test that derives widths from real Marshal.dump output.
The symlink and object-link bounds checks no longer lean on negative-index
wraparound. The parse graph is sealed before it is returned - every node, its
collections and its scalars frozen - and the whole suite stayed green first
try, which proves nothing downstream was mutating it. exploit-gate.sh pins
both sides now, 4.0.2-slim erb 6.0.1 FIRED against 4.0.6-slim erb 6.0.1.1
BLOCKED; the finding's claim about detector-gate.sh was wrong, it never had
a patched side.
Lint (B30). just lint used a --network none runner, so gem install could
never reach RubyGems, the && short-circuited, and the recipe exited 2 while
printing absolutely nothing. That is the fourth instance of a dropped return
value hiding a failure in this project. It is loud now. The config had never
been validated against a real run: 903 offenses, dominated by a quote style
the codebase does not use. frozen_string_literal was verified safe by running
the whole suite under RUBYOPT=--enable-frozen-string-literal BEFORE the change,
so ~247 offenses were retired by fixing code rather than silencing a cop.
Every remaining disabled cop carries a reason.
README's Status section listed the scanner, matrix, payload builder, target
and detector as "planned". All five have shipped. It also taught
Parser.new(blob) as the API, which was the unbounded form until this branch
made bounded limits the default.
Status now names all six components. Usage teaches the bounded default,
points at Limits.permissive for forensic parsing of a stream you already
trust, shows the detector alongside the parser, and points readers at
LIMITATION_NOTICE before they rely on an accept. Development lists all
eleven just recipes instead of five.
Every code example in the README was executed verbatim before this commit
and produces exactly the output it claims.
CHANGELOG gains Changed and Fixed sections covering the default-limits
change and the six defects closed on this branch.
Every item contracted to clear before M7 is closed. 151 tests from 119,
58 corpus cases from 48, all six gates green.
Depth accounting (B3, B4). TAG_IVAR charged no depth at all, so an I-chain
of any length parsed under any ceiling. Proven end to end against a rebuilt
target image: a 12,936-byte cookie returned HTTP 500 with a SystemStackError
that no rescue StreamError can catch, and a 724,287-byte response body
leaking absolute container paths for every file in lib/. Fixed, the same
cookie returns 400 DepthLimitError, and so does a 53,340-byte one.
read_userdef also hard-coded a depth of 1 for its class-name slot.
Budget axes (B12, B13, B14). Bignum magnitude bypassed the scalar budget
entirely and the sign byte accepted anything as positive where Marshal.load
raises ArgumentError. Added max_symbol_references, max_symbol_name_bytes,
max_class_name_bytes, max_instance_variables and max_struct_members.
Parser.new now enforces Limits.new instead of resolving to an unbounded
config; Limits.permissive became a class method.
Hash-key dispatch (B11). Nothing rejected an allowlisted class used as a
hash KEY, where #hash and #eql? run during load before any allowlist can
act. Measured against real Marshal.load: a key dispatches iff it carries a
class name and its underlying value is not a T_STRING. So TAG_REGEXP is not
a key-position risk and TAG_USERCLASS only conditionally - rejecting either
outright would have been a false positive. No opt-out allowlist was added,
because the dispatch happens before any check could run.
Fidelity (B15, B16, B9). The parser already matched Marshal.load on header
versions, so the 4.8 contradiction was resolved by giving the detector the
policy check and leaving the forensic parser permissive. Class-name slots
now accept only a symbol, an ivar-wrapped symbol, or a symlink. Wrapper
tags C and e no longer take an object-table slot, which Ruby does not give
them - link index 3 resolved to "bbb" for us and "ccc" for Ruby.
Gate soundness (B6, B7). Three discarded check() return values now register
as failures; section 6 no longer reports a vacuous 0/0; section 7 requires
reachable > 0, and prism absence is a named failure rather than a silent
zero. version-matrix.sh exits non-zero when any image produces no probe
result. control_check.rb no longer pulls in minitest, which was printing
"0 runs, 0 assertions" directly under ALL CONTROLS PASSED. Rewrote the
vacuous tests: the regexp options byte had zero minitest coverage and its
mutant survived the whole suite, and read_count's negative guard was
alibied by take's own guard.
The target app (B5) lost its hand-rolled copy of the sink-plus-allowlist
policy and now runs one BoundaryDetector with real limits, branching on
rejected? rather than accepted?.
Everything here is mutation-proven. Notable misses that mutation caught:
B14 had no test at all until reverting it stayed green, and a struct-member
test was vacuous on the first attempt because struct member names are always
symbols.
lib/rube/marshal/parser.rb carries eight backlog items at once and cannot be
split without interactive hunk staging, so this is one commit rather than
eight.
Duplicate ivar names deleted subtrees and five of six readers threw away the
class-name node, so a gadget in either position was invisible to the detector
while Marshal.load still fired it. Both had working proofs; the suite was green
the whole time.
The parse graph now has exactly one traversal owner. read_class_name and
read_instance_variables push every class-name node, ivar name and ivar value
into auxiliary, and Node#each no longer walks instance_variables_map. The map
stays as a lookup convenience with last-write-wins semantics, it just is not
load-bearing for security any more. Walking both would have double-counted
every ivar value.
Corpus entries take an optional allowlist. Without one every case ran through
an empty strict allowlist where any class name rejects, which is why the corpus
could not express B2 at all. The 40 existing cases default to [] and are
unchanged.
At the tag level there are seven class-name slots, not six: o S u U d C e, and
only o retained its node. u, U and d are themselves sink tags so a corpus case
there can never fail; those three are asserted at the parser level instead and
a test pins the exclusion as deliberate rather than an oversight.
Verified by mutation, since green means nothing on this project. Dropping the
class-name push, dropping the ivar value push, and restoring the map walk each
now fail 2, 4 and 3 tests. The first two previously survived the entire suite.
119 tests from 110, 48 corpus cases from 40. test, control, exploit, detector,
target and matrix all pass.
Forty explicit Marshal streams built from tag bytes rather than Marshal.dump,
so the corpus stays valid when Ruby changes what it emits. Thirteen must be
accepted, twenty-seven must be rejected across eight distinct error types.
The accept half is what gives the reject half meaning. A detector that
rejected everything would fail thirteen cases here, including legitimate
cycles, shared object references, symlink reuse, and fixnums at every encoding
width.
Provenance note, stated because it weakens the evidence: Codex was asked to
build this corpus and the request was blocked by its provider's content filter,
which reads constructing hostile streams as offensive tooling. The request was
not reworded to get around that. The corpus was therefore written by the same
model that wrote the detector, which is exactly the arrangement the split
exists to avoid, and it should be treated as weaker than the negative-count
findings Codex produced independently.
The boundary of what Codex can contribute here is now mapped. Defensive
research, packaging research, defensive design and acceptance criteria, and
probing the parser during a design task all succeeded. Researching gadget
chains and building a hostile corpus were both refused.
Also fixes a bug in the corpus fixnum encoder, which computed the width marker
as width plus 256 and raised RangeError for any positive multibyte value.
110 tests, 244 assertions across five suites.
Implements the detector Codex specified while it had no implementation to look
at, so neither model defined and graded the same thing.
Parser-level budgets rather than post-parse checks. Codex's architectural point
was that inspecting after parsing is too late because the allocation already
happened, so Limits and Budget enforce byte size, depth, node count, registered
objects, symbol definitions, collection entries, scalar bytes and object links
DURING recursive descent. The size ceiling is checked before the parser is
constructed and non-String input is rejected without ever calling to_s.
Three policies, STRICT_ALLOWLIST as the default, on the reasoning that people
keep defaults far longer than they intend. No enforcing mode accepts an
allowed_sinks option, because permitting a class-and-sink pair still authorizes
a callback during load. OBSERVE_AND_LOG refuses to construct without a
reporter. Allowlisting a class does NOT exempt its sinks, and that is a test.
No method is named safe?, trusted?, sanitized? or safe_load, and a test asserts
their absence. Those names claim a guarantee this cannot make.
The gate demonstrates the documented bypass rather than asserting it. Under
DENY_SINKS_ONLY the detector ACCEPTS the CVE-2026-41316 payload, and the gate
then loads that accepted snapshot on vulnerable erb and confirms the canary
fires. Our own detector, in a shipped mode, admits a payload that achieves code
execution. That is the limitation notice being true rather than decorative, and
if it ever stops being demonstrable the gate fails.
The notice ships verbatim and names the bypass concretely: a payload carrying
no sink tag can still reach dangerous code, the published chain produces zero
sink tags because ERB defines no marshal_load, and an application that
allowlists ERB will accept it.
106 tests, 235 assertions across four suites. Five gates: check, matrix,
exploit, detector, target.
Codex, working the defensive half without having written the parser, probed M1
with adversarial input and found defects my own tests missed. Verified
independently before fixing, and two more were found while confirming:
negative_array_count ACCEPTED as an empty array
negative_hash_count ACCEPTED as an empty hash
negative_bignum_words NoMethodError leaked outside StreamError
negative_ivar_count ACCEPTED
negative_string_len cursor moved BACKWARD, wrong error raised
The last is the worst. take(-5) does not trip the count > remaining guard, so
byteslice returns nil and @position decreases. A parser whose cursor can rewind
on attacker input is a loop primitive, not merely a wrong error.
The M1 gate claimed bignum length confusion was covered. It was not. Oversized
widths were tested and negative counts never were, because the same author
chose both the implementation and the cases it would face. That is the
negative-control failure one level up, and it is exactly what an author cannot
catch alone.
Fixes: every count and length now flows through read_count with a role label
and a nonnegative check, take rejects negative byte counts outright, and
MalformedCountError joins the StreamError hierarchy so nothing leaks a raw
NoMethodError. Negative link indices were already guarded; regression tests now
pin that.
Also closes a detection blind spot Codex identified. read_ivar and read_object
discarded the parsed name nodes after taking their values, so a sink tag placed
in an instance-variable-name position vanished from Result#sinks. Node now
carries an auxiliary collection that Node#each traverses, and name and class
nodes are retained. Proven: a stream with a userdef tag in the name position
now reports Evil#_load where it previously reported nothing.
46 parser tests, 80 assertions. All controls pass, exploit gate still passes.
Sinatra on webrick in a container, storing session state as a base64 Marshal
blob in a cookie. Three endpoints: /render deserializes and compiles the
session template, /render/safe inspects the stream first, /canary reports
execution. Runs read-only, unprivileged, with a 1MB noexec tmpfs, on a high
configurable host port.
Gate proves three things and the third is what stops the defense being a brick:
PASS HTTP request achieved code execution through Marshal.load
PASS defended endpoint rejected the identical payload
PASS defended endpoint still serves a legitimate session
Two findings that change the defensive design.
Sink tags do not catch this chain. The working payload produces ZERO sink-tag
hits. ERB defines no marshal_load, so it serializes as a plain object with
instance variables and carries no u, U or d tag. The defended endpoint rejected
it on the class allowlist, and an application that allowlisted ERB as a
legitimate template class would have passed it through untouched. A gadget does
not need a marshal_load hook, it needs an object whose ivars the application
later feeds to a dangerous method. The dangerous call site lives in the
application, not in the serialized class. Any policy treating absence of sink
tags as safe is defeated by this exact public payload.
A legitimately initialized ERB cannot be serialized at all. @_init holds
self.class.singleton_class and Marshal raises TypeError: singleton class can't
be dumped. So the guard is not a flag an attacker might satisfy, it is anchored
to a value the serializer physically cannot reproduce. Any ERB an attacker can
serialize necessarily lacks a valid @_init. The generalized pattern for learn/:
do not validate the untrusted object, anchor trust to something unreachable
through the channel.
Also fixes a gate that skipped a control silently. The benign-session check
produced no output because POST with no body returns WEBrick LengthRequired,
and the script treated an empty result as nothing to test rather than as a
failure. It now fails loudly.
70 tests, 151 assertions, 0 failures. Target app excluded from the gem
manifest, verified at 0 files.
test_generate_returns_an_object_not_bytes errored on roughly 1 run in 12
under Minitest's random ordering.
assert_kind_of ERB, chain.generate evaluates the ERB constant before calling
generate, and generate was performing a lazy require of erb inside itself. If
that test ran before anything else had loaded erb, the constant did not exist.
The lazy require was the defect rather than the test. A require buried in a
method makes constant availability depend on call order for every caller, not
just this one. Moved to the top of the file.
Verified across 40 randomized runs, 0 unstable. The previous commit shipped
this suite showing 1 error and should not have.
Chain registry modelled on PHPGGC: the class is the chain identity, metadata
carries the CVE and its affected version ranges, generate returns an object
rather than bytes, and serialization is a separate step.
Ships the ErbDefMethod chain for CVE-2026-41316. Ruby 2.7.0 added an @_init
guard to stop Marshal.load code execution on ERB objects, and def_method never
checked it. def_module and def_class delegate to def_method, so the single
missing check exposed all three entry points for six years.
The payload is an ERB built by allocate with @src, @filename and @lineno set
and @_init deliberately absent. @src opens with a comment line and a bare end
so that the def wrapper def_method injects is closed before the payload runs,
which puts execution at eval time rather than at call time.
Gate proves both halves and neither alone is sufficient:
4.0.2-slim erb=6.0.1 outcome=FIRED predicted=FIRED
4.0-slim erb=6.0.1.1 outcome=BLOCKED predicted=BLOCKED
The prediction column is the load-bearing one. affects? evaluates the CVE
ranges encoded in the chain metadata against the erb version present in the
image, before the payload runs. Observed behaviour matched on both, so the
registry is making falsifiable claims rather than carrying documentation.
Exploit containers run with no network, a read-only root filesystem, a 1MB
noexec tmpfs and an unprivileged user. The parser also inspects the payload
and reports ERB without deserializing it, so the offensive and defensive
halves meet on the same artifact.
70 tests, 151 assertions across four suites.
Explanatory prose in puts statements is commentary living in code. Removed
from control_check.rb and render_matrix.rb, leaving facts and verdicts.
Controls now report as a uniform PASS/FAIL table with a single exit status,
which also makes them usable as a gate rather than something a human reads.
Records the resolution on the two third-party findings: both dropped, no
disclosure, no further investigation. Neither was independently verified, no
obligation attaches to unpublished observations, and the lab has a stronger
flagship in CVE-2026-41316. Kept as unverified leads in gitignored docs.
Walks the live class graph and classifies auto-invoked methods along the
gated/ungated dispatch axis. Marshal calls respond_to? before invoking
marshal_load and _load, while hash, eql?, <=>, []= and to_s are dispatched
blind, so a class can be dead as a Marshal entry point and live as a #hash
entry point.
Rediscovers Gem::Requirement#marshal_load, Gem::Version#marshal_load and
Gem::Specification._load with source locations, from reflection alone, with no
class names hardcoded anywhere in the scanner.
A new control asserts the M1 parser and the M3 scanner agree. The parser reads
bytes off a payload, the scanner walks the class graph, neither consults the
other, and every sink the parser finds in a real Gem::Requirement payload is
one the scanner independently located. Two routes to the same fact.
Raw output was unusable at 163 ungated candidates dominated by to_s and hash,
which nearly every class defines. The reachability predicate from the research
narrows that to methods that are zero-arity AND touch instance state, cutting
163 to 12 and keeping 7.4 percent. Gated sinks are always reachable.
Two corrections found while building the predicate:
RubyVM::AbstractSyntaxTree.of does not work on Ruby 4.0. It raises
'cannot get AST for ISEQ compiled by prism' because prism is now the default
parser. Any tool reaching for that API on modern Ruby is broken. Replaced with
Prism, which ships as a default gem.
The literal reads-its-own-ivars predicate is too narrow. Gem::Requirement#hash
calls the requirements attr_reader rather than @requirements, so an instance
variable check reports false on a method that plainly operates on instance
state. Widened to instance variable reads OR implicit-self calls.
55 tests, 112 assertions, 0 failures across both suites.
Probes six pinned Ruby images with no network and renders a compatibility
matrix for the deserialization gadget surface. Reproducible with just matrix.
The matrix carries three controls, because a table that reports one value
everywhere cannot be distinguished from a probe that always returns the same
answer. Two axes must show more than one state, and the ERB guard column is
cross-checked against the published CVE-2026-41316 affected ranges. That third
control is the load-bearing one: the probe reads source and knows nothing about
NVD, and it agrees with the advisory on 6 of 6 images.
Findings recorded in the research docs:
The gadget surface moved rather than shrank. Net::WriteAdapter is reachable at
baseline on Ruby 3.1 and 3.2 and gone from 3.3 onward, which is why vakzz-era
chains needed no preloaded net/http on old Ruby. Gem::URI appears in the same
release that took it away, and Gem::URI reached through an autoloaded
Gem::SpecFetcher is exactly the bootstrap the 2024 chain relies on. One door
closed and another opened in the same version, so a defense reasoning about
the known gadget classes is reasoning about a moving target.
The ERB @_init guard sits only on def_method. def_module and def_class delegate
to it in both vulnerable and patched releases, so one check covers all three.
The first probe measured all three independently and reported the delegates as
unguarded even on patched erb 6.0.1.1. That was the probe being wrong, not the
patch being incomplete, and it is corrected here.
Marshal stream format is 4.8 on every image, so the M1 parser applies across
the whole range unchanged. make and git are absent from every slim image, so
rake is the only exec binary present on that family.
Scaffolds the Ruby deserialization security lab and lands its defensive core
first: a parser that extracts structure, referenced class names, and gadget
sinks from a Marshal stream without ever calling Marshal.load.
Sinks are classified along the gated/ungated dispatch axis. Marshal checks
respond_to? before invoking marshal_load and _load, while hash, eql?, <=> and
[]= are dispatched blind, so the same class can be dead as a Marshal entry
point and live as a #hash entry point.
Object links are ZERO-indexed. Ruby's Marshal format documentation says
one-indexed and is wrong: a self-referential array dumps as 04 08 5b 06 40 00
with the trailing 00 linking to the outermost object. Written against observed
bytes rather than the docs.
Validation rejects truncated streams, unsupported version bytes, unknown type
tags, out-of-bounds object links and symlinks, oversized fixnum widths,
trailing bytes, and nesting past a configurable depth limit.
A negative-control script accompanies the suite and caught a test that was
passing vacuously: the TracePoint oracle watched :c_call, but Marshal.load is
a Ruby-level method in Ruby 4.0 (<internal:marshal>:33) and fires :call, so
the test could never have failed. The suite now asserts the oracle observes a
real Marshal.load before the negative assertion is allowed to mean anything.
Gem manifest is an explicit allowlist rather than git ls-files, so the
deliberately vulnerable target cannot be swept into a published gem later.
34 tests, 62 assertions, 0 failures. 52/52 corpus round-trip. gem build
--strict clean. All execution in ruby:4.0-slim with --network none.
Vertically wrap the long table-driven case literals and one Fatalf call to
satisfy the project's golines linter (max-len 80, reformat-tags), which the CI
Go lint job flagged. Formatting only; no test behavior change.
The lint job installs ruff unpinned (pip install ruff), which reached 0.15.22
and, under preview = true, promoted the rule-codes-in-selectors diagnostic that
rejects rule codes like E501/S101 in lint.ignore. This project was the only one
with preview enabled. Dropping preview keeps the code-based selectors valid on
every ruff version in play (0.15.1 pre-commit pin, 0.15.7, 0.15.22 CI) and
aligns it with the other Python projects. No rule coverage change.
Add the five-part learn/ folder (overview, concepts, architecture,
implementation, challenges) grounded in docs/research, with the
QR-from-ISO/IEC-18004 Reed-Solomon injection as the showpiece and crypha's
real capacities cited from the binary. Update the root README row from a
Python synopsis to the built Go project with Source Code and Docs links,
matching the nadezhda row.
RealIP only reads XFF when the immediate peer sits in TRUSTED_PROXY_CIDRS,
so an untrusted client can no longer spoof its source IP while a real client
behind a known reverse proxy still resolves correctly. Parses the
comma-separated list via a knadh/koanf ProviderWithValue callback (blank or
whitespace-only input leaves the built-in default intact), wires the var
through both compose files and .env.example, and adds MYSQL_FAKE_* and
TURNSTILE_SECRET env aliases. Covered by config_test.go.
Guided hide/reveal/capacity wizard in internal/tui as a pure view over internal/engine (no carrier logic): HCL gradient engine, live capacity meter, embed animation, and a secure-options form. Bare crypha on a TTY launches it; piped or --help prints help. Adds engine.Overhead and engine.EnvelopeSize for exact payload-fit preflight (flate pass only, no KDF).
Reimplements the QR internals needed for the covert channel from
ISO/IEC 18004: the function-module map, format-info parse, data mask,
zigzag placement, and error-correction block de-interleave, plus a
from-scratch Reed-Solomon decoder over GF(2^8) (syndromes, Berlekamp-
Massey, Chien search, and a Vandermonde magnitude solve). skip2 generates
the clean symbol; crypha introspects it and reuses none of its internals.
The payload is hidden as up to floor(t/2) correctable codeword errors per
block in the data region, leaving the error-correction codewords intact,
so any scanner's Reed-Solomon decoder self-heals to the cover and never
sees it. Reveal reads the module grid, RS-decodes each block itself, and
diffs the corrected data against the stego to recover the payload. EC
level is fixed at H; versions 1-10 auto-select by cover and payload size.
Capacity is tens of bytes, so an encrypted envelope (which exceeds it) is
rejected cleanly.
Differentially tested: the extracted codewords match skip2 as valid RS
codewords across all supported versions, and every stego still scans back
to the cover via gozxing at the full injection budget. skip2 is a runtime
dependency (the generator); gozxing is test-only. No toolchain bump; the
go directive stays 1.25.0.
M4 audio: 16-bit PCM WAV LSB carrier. Cover input is WAV or FLAC
(FLAC decoded via the mewkiz decoder); output is always 16-bit PCM
WAV. Native FLAC output is deferred. uint32 length-prefix framing
with overflow-safe bounds; in-memory WriteSeeker so the WAV encoder
can seek back and patch chunk sizes through the io.Writer interface.
M5 pdf: three techniques behind one carrier. Attachment (default,
pdfcpu embedded-file, lossless), metadata (base64url payload chunked
across custom Info-dict keys), and append-after-EOF (raw trailing
bytes, O(1) end-seek). Reveal auto-tries all three; technique
selection is exposed via New(Technique). The package disables the
pdfcpu config directory for hermeticity.
Both carriers self-register and are blank-imported in carrier/all.
No toolchain bump (go directive stays 1.25.0).
M2 image carrier: LSB embedding in PNG and 24-bit BMP covers via the
mandatory NRGBA conversion (avoids the premultiply LSB-corruption trap on
both encode paths), RGB-only with alpha untouched, uint32 length prefix,
paletted/16-bit/JPEG rejection, Capacity and Sniff, self-registering.
M3 text carrier: encrypted payload hidden as zero-width Unicode
(U+200B/U+2060) appended after cover text, magic + length framing that
survives incidental zero-width in the cover and NFC/NFD/NFKC/NFKD
normalization. Frame extraction consumes exactly to the end of the
carrier-bit stream so nested stego reveals the last-hidden layer.
New internal/carrier/all sentinel blank-imports carriers into the
registry. Adds golang.org/x/image/bmp and (test-only) golang.org/x/text.