Input state framing guard - #234
Closed
supertypo wants to merge 4 commits into
Closed
Conversation
readInputState and readInputStateWithTemplate decode a foreign input's state fields at offsets fixed at compile time. A contract's redeem script is PREFIX || state || SUFFIX, where the state region is a sequence of plain data pushes. The template hash authenticates the code and the total length and the P2SH check authenticates that the bytes read are the committed script, but neither says how the region between prefix and suffix is framed. The script engine accepts non-minimal push encodings, so a foreign input can widen one field's push header and narrow another's, keep the region's total byte length identical, and move every later field read onto bytes of its own choosing. These tests execute real script on the Kaspa engine and currently fail: they assert the reframes are refused, and today they are decoded. Each reject case is paired with a positive control so a later rejection is attributable to framing rather than an unrelated malformation, and a length-changing reframe is asserted separately so the existing length pin is not credited to a framing check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Require each state field's push header to equal data_prefix(payload_len) at its constant offset, the header the state encoder itself emits. That forces the whole region to be canonically framed by induction: the first header sits at the region start, and each pinned header determines its own payload width and so the offset of the next one, with the region's total length already pinned by the existing arithmetic. The whole header slice is compared rather than its first byte. A payload of 76 bytes or more is pushed with OpPushData1 and 256 or more with OpPushData2, so pinning only the first byte would leave the length byte free, and a header can also be re-encoded at a different width while the chunk keeps its length. The state region is read once per call site and every header is compared against that single stack value, which costs one sigscript introspection plus one comparison per field. Deriving each header's position from the sigscript separately measured four times more expensive. No language surface changes, so existing sources compile unchanged. Contracts that decode foreign input state do emit extra opcodes, so their bytecode, template hash and P2SH address all change and they must be recompiled with any embedded template hash re-derived. The chess size snapshots and the expected-script assertion move for that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
readInputState anchors its window at sigscript_len(idx) - this.bytecodeSize, the reader's own constant, which says nothing about how long the foreign script is. A script one byte longer shifts every field read by one, and the framing guard then checks its push headers at the shifted offsets — against bytes the forger supplies. One byte of padding buys both header positions, so the guard passes while the reader takes its fields from values the forger chose. The test asserts the read is refused, and today it is decoded. readInputStateWithTemplate is not exposed to this: it requires the window's P2SH to equal the input's own scriptPubKey, and a scriptPubKey commits to the whole redeem script, so a longer script cannot present a shorter window and still match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The framing guard pins each field's push header at a constant offset, but not where those offsets are measured from. readInputState places its window at sigscript_len(idx) - this.bytecodeSize, which describes the reader rather than the script being read, so a longer foreign script shifts every read and the guard follows the window onto bytes the forger chose. Pinning the framing of a window whose position is unconstrained proves nothing. Require P2SH(window) to equal the input's scriptPubKey, as readInputStateWithTemplate already does. A scriptPubKey commits to the whole redeem script, so a longer script would present only a suffix in the window and could not match: the foreign script is exactly bytecodeSize bytes, and is the one its own UTXO committed to. Three runtime tests gave the read input a bare OP_TRUE scriptPubKey — a script its UTXO never committed to, which no chain would produce — and now set a real P2SH one. Two test helpers pushed a redeem with add_data, capped at the 520-byte standard element limit, and now use the explicit push a real signature script uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
|
Closed pending further discussion |
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.
Pin the framing and the position of foreign input state reads
TL;DR
readInputStateandreadInputStateWithTemplatelet a contract read another input's state.They locate each field by counting a fixed number of bytes from the start of that input's state
region — first field at byte 0, second at byte 9, and so on, all decided at compile time.
Two separate things have to hold for that to mean anything, and neither was checked.
The region has to be packed the way the compiler packs it. Kaspa's engine accepts more than
one encoding for the same push: an 8-byte value takes a 1-byte header, or a 2-byte header via
OP_PUSHDATA1. So a hostile input can lengthen one field's header and shorten another's payloadto pay for it. The region's total length is unchanged, the code is untouched, the template hash
and the P2SH commitment still match — and every field after the lengthened one has slid.
The region has to be where the reader thinks it is.
readInputStateanchors its window atinput_sigscript_len(idx) - this.bytecodeSize, which describes the reader, not the script beingread. A foreign script one byte longer shifts every read by one.
A concrete example. A lending vault takes a token as collateral and reads the token input's
state to see how much is there:
The compiler encodes that as two pushes, 42 bytes:
so the vault reads
amountfrom 1..9 andownerfrom 10..42. The attacker deploys their owninstance of the same token template, packed differently — still 42 bytes:
Ten plus thirty-two is still forty-two, so nothing the vault checks has changed. But its reads now
land a byte early:
amountreads08 a0 a1 a2 a3 a4 a5 a6, a number the attacker picked, andownerreads1f o0 … o30. The attacker holds a token worth 1, the vault reads 2^59, and lendsagainst collateral that does not exist. Nothing the vault could have written would have caught it
— the token really is the genuine template, byte-for-byte, at the P2SH of those bytes.
This is reachable for any contract that reads a foreign input whose instances an attacker can
create, which is the normal case for tokens, pools, orders and escrows. A design is safe today
only if it can argue that every instance it will ever read was built by a covenant that encoded
the state itself — an argument outside the compiler's control that every user of these builtins
currently has to reconstruct alone.
The defect
A redeem script is
PREFIX ‖ state ‖ SUFFIX, the state region being one data push per field.data_prefix(payload_len)picks the header the encoder writes:readInputStateWithTemplateemits, before decoding:Those authenticate the script's code and its total length. They say nothing about how the
bytes between prefix and suffix are framed, and the field reads that follow use offsets
computed at compile time from the canonical framing. Redistributing bytes between fields while
preserving the total defeats all of it.
readInputStatehas the framing problem too, and a second one on top. It emits neither of thechecks above — no template hash, and no P2SH commitment — so nothing ties
bytecode_sizeto theforeign script's real length. A longer script slides the whole window, and pinning the framing
inside a window whose position is unconstrained proves nothing: the forger simply pays a byte of
padding to place canonical-looking headers where the shifted read will look for them.
The fix
Pin the framing. Require each field's push header to equal
data_prefix(payload_len)at thatfield's constant offset — the header the encoder itself emits.
That is sufficient, not merely necessary. The region start is pinned by the
bytecode_sizearithmetic, so the first header sits at a known offset; pinning it fixes that field's payload
width, which fixes where the next header begins, and so on. The pins land exactly where a linear
parse of the region arrives, and the framing is canonical by induction from the region start. The
total length is already pinned, so no slack remains after the last field.
Pin the position. For
readInputState, additionally requireA scriptPubKey commits to the whole redeem script, so a longer script would present only a
proper suffix in the window and could not match. The foreign script is therefore exactly
bytecode_sizebytes, and is the one its own UTXO committed to. This is the checkreadInputStateWithTemplatealready makes, which is why only the plain decoder needed it.Two properties decide whether the framing pin is complete and affordable.
The whole header slice is compared, not its first byte. For a payload of 76 bytes or more the
canonical header is
OP_PUSHDATA1 <len>. Pinning only the opcode would leave the length bytefree:
4c 50could become4c 4f, shortening that chunk by one and sliding everything after it.A header can also be re-encoded at a different width while keeping the chunk length —
4c 50and4d 4f 00both occupy 82 bytes — so the comparison covers the fulldata_prefixslice.The state region is read once per call site. Deriving each header's position from the
signature script independently measured +1418 bytes on
player.sil; reading the region once ontothe stack and comparing every header against that single value costs +354.
No language surface changes: no new syntax, no new builtin, no signature change. Existing sources
compile unchanged.
Emitted code
Once per call site, leaving the state region on the stack:
Then per field:
and a final
OP_DROP. Stack-neutral, inspecting a copy. The plain decoder emits the scriptPubKeybinding ahead of this, so the window is fixed before anything inside it is pinned.
The guard is emitted from the same flattened layout and offset walk that builds the field reads,
so the pinned offsets cannot drift from the decoded ones. Both builtins are covered in every
syntactic position they are legal in: direct struct binding, destructuring assignment, expression
position, and the reads generated by covenant declaration lowering.
Cost
player.silchess_settle.silContracts that do not decode foreign input state are unaffected. The scriptPubKey binding adds one
sigscript read and one comparison per
readInputStatecall site; the contracts above use thetemplated decoder, which already carried that check, so it does not appear in these figures.
Compatibility
Every contract that decodes foreign input state emits additional opcodes, so its bytecode,
template hash and P2SH address all change. Such contracts must be recompiled and any embedded
template hash re-derived — including
expectedTemplateHashconstructor arguments, as inkcc20-minter.sil, where a newly compiled minter will not match a deployment made before this.Three runtime tests gave the read input a bare
OP_TRUEscriptPubKey — a script its own UTXOnever committed to, which no chain would produce — and now build a real P2SH one. Two test helpers
pushed a redeem with
add_data, capped at the 520-byte standard element limit, and now use theexplicit push a real signature script uses.
Tests
silverscript-lang/tests/input_state_framing_tests.rsexecutes real script on the Kaspa engine.Each fix is committed after its failing tests, so the history shows them failing against the
unmodified compiler: eight forgeries execute successfully there and are refused after. Every
rejection case is paired with a positive control, so a refusal is attributable to the check under
test rather than an unrelated malformation.
readInputStateWithTemplate,readInputState, andreadInputStatein expression position.of padding buys both header positions. Through
readInputStateand expression position.the new checks.
case a first-byte-only check would miss.
field's pin and no pin can be dropped without a test failing.
Notes for review
read_input_state_field_expr_symbolic, reached fromstructs/expr_lowering.rs, builds the sameconstant-offset reads without either check. It is currently unreachable —
StructLowererisconstructed with
state_end: 0, so every route into it fails on an arithmetic underflow beforecodegen — but wiring up a real
state_endwould silently reopen this.