Skip to content

Input state framing guard - #234

Closed
supertypo wants to merge 4 commits into
kaspanet:masterfrom
supertypo:input-state-framing-guard
Closed

Input state framing guard#234
supertypo wants to merge 4 commits into
kaspanet:masterfrom
supertypo:input-state-framing-guard

Conversation

@supertypo

@supertypo supertypo commented Aug 29, 2026

Copy link
Copy Markdown

Pin the framing and the position of foreign input state reads

TL;DR

readInputState and readInputStateWithTemplate let 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 payload
to 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. readInputState anchors its window at
input_sigscript_len(idx) - this.bytecodeSize, which describes the reader, not the script being
read. 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:

struct TokenState {
    int amount;
    byte[32] owner;
}

The compiler encodes that as two pushes, 42 bytes:

08 <8 bytes: amount>    20 <32 bytes: owner>
└── 9 bytes ──────┘     └── 33 bytes ─────┘

so the vault reads amount from 1..9 and owner from 10..42. The attacker deploys their own
instance of the same token template, packed differently — still 42 bytes:

4c 08 <8 bytes>    1f <31 bytes>
└── 10 bytes ─┘    └── 32 bytes ┘

Ten plus thirty-two is still forty-two, so nothing the vault checks has changed. But its reads now
land a byte early: amount reads 08 a0 a1 a2 a3 a4 a5 a6, a number the attacker picked, and
owner reads 1f o0 … o30. The attacker holds a token worth 1, the vault reads 2^59, and lends
against 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:

payload ≤ 75      →  <len>                    (1 byte)
76 ≤ payload      →  OP_PUSHDATA1 <len>       (2 bytes)
256 ≤ payload     →  OP_PUSHDATA2 <lo> <hi>   (3 bytes)

readInputStateWithTemplate emits, before decoding:

bytecode_size = template_prefix_len + encoded_state_len + template_suffix_len
bytecode_base = input_sigscript_len(idx) - bytecode_size

require blake3(i64le(prefix_len) ‖ prefix ‖ i64le(suffix_len) ‖ suffix) == expected_template_hash
require input_script_pubkey(idx) == P2SH(input_sigscript[base .. base + bytecode_size])

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.

readInputState has the framing problem too, and a second one on top. It emits neither of the
checks above — no template hash, and no P2SH commitment — so nothing ties bytecode_size to the
foreign 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 that
field's constant offset — the header the encoder itself emits.

That is sufficient, not merely necessary. The region start is pinned by the bytecode_size
arithmetic, 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 require

require input_script_pubkey(idx) == P2SH(input_sigscript[base .. base + bytecode_size])

A 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_size bytes, and is the one its own UTXO committed to. This is the check
readInputStateWithTemplate already 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 byte
free: 4c 50 could become 4c 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 50 and
4d 4f 00 both occupy 82 bytes — so the comparison covers the full data_prefix slice.

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 onto
the 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:

PUSH input_idx
OP_DUP
OP_TXINPUTSCRIPTSIGLEN
PUSH bytecode_size
OP_SUB
PUSH state_start_offset
OP_ADD
OP_DUP
PUSH region_len
OP_ADD
OP_TXINPUTSCRIPTSIGSUBSTR

Then per field:

OP_DUP
PUSH header_offset
PUSH header_end
OP_SUBSTR
PUSH canonical_header
OP_EQUALVERIFY

and a final OP_DROP. Stack-neutral, inspecting a copy. The plain decoder emits the scriptPubKey
binding 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

contract bytecode instructions charged ops
player.sil 3437 → 3791 2466 → 2691 1640 → 1766
chess_settle.sil 2830 → 3124 2131 → 2314 1394 → 1494

Contracts that do not decode foreign input state are unaffected. The scriptPubKey binding adds one
sigscript read and one comparison per readInputState call site; the contracts above use the
templated 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 expectedTemplateHash constructor arguments, as in
kcc20-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_TRUE scriptPubKey — a script its own UTXO
never 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 the
explicit push a real signature script uses.

Tests

silverscript-lang/tests/input_state_framing_tests.rs executes 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.

  • Length-preserving reframes through readInputStateWithTemplate, readInputState, and
    readInputState in expression position.
  • A longer foreign script, framed so the shifted reads still land on canonical headers — one byte
    of padding buys both header positions. Through readInputState and expression position.
  • A length-changing reframe, asserted separately so the pre-existing length pin is not credited to
    the new checks.
  • A field whose canonical header is two bytes, reframed by changing only its length byte — the
    case a first-byte-only check would miss.
  • That same wide field placed first, in the middle and last, so each forgery violates exactly one
    field's pin and no pin can be dropped without a test failing.

Notes for review

read_input_state_field_expr_symbolic, reached from structs/expr_lowering.rs, builds the same
constant-offset reads without either check. It is currently unreachable — StructLowerer is
constructed with state_end: 0, so every route into it fails on an arithmetic underflow before
codegen — but wiring up a real state_end would silently reopen this.

supertypo and others added 4 commits August 29, 2026 17:22
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>
@supertypo

Copy link
Copy Markdown
Author

Closed pending further discussion

@supertypo supertypo closed this Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant