TECHNICAL REPORT · FSA-2026-001 · 27 August 2026
A sign task’s outputs were never checked against the signing key
Component:
frostsnap_core, device-side sign task validation and the confirmation prompt
Present in the codebase: 18 July 2024 to 3 August 2026
Affected releases: every release up to and including v0.3.0
Status: fixed by three pull requests, released in v0.4.0
Prepared: 27 August 2026, from the project’s commits, pull requests and code
Contents
- Summary
- Timeline
- 1 · Background
- 2 · The two halves
- 3 · Exploitation
- 4 · Exposure in practice
- 5 · The fixes
- 6 · Review by current AI models
- 7 · Origin
Summary
WireSignTask::check is the device’s validation of a signing request. It checked the request’s purpose, its fee, and every input’s owner against the signing key. It never examined an output’s claimed owner:
let non_matching_key = tx_template.inputs().iter().find_map(|input| {
let owner = input.owner().local_owner()?;
if owner.master_appkey != master_appkey {
Some(owner.master_appkey)
} else {
None
}
});
TransactionTemplate::user_prompt built the confirmation screen from foreign_recipients() alone, so every output marked SpkOwner::Local was left off the device’s display.
The script pubkey of a local output is not supplied; it is derived from the appkey the label names:
impl LocalSpk {
pub fn spk(&self) -> ScriptBuf {
let expected_external_xonly =
AppTweak::Bitcoin(self.bip32_path).derive_xonly_key(&self.master_appkey.to_xpub());
ScriptBuf::new_p2tr_tweaked(TweakedPublicKey::dangerous_assume_tweaked(
expected_external_xonly.into(),
))
}
}
A request could therefore label an output with an appkey the signer does not own. Nothing checked the label, and the label was the reason the output was not shown. A malicious coordinator could put an output in a transaction that the device neither verified nor displayed, and the user would unknowingly approve it on the device.
Found by Frostsnap’s internal security review, assisted by the Kimi K3 model, and reproduced with a proof of concept the same day. Fixed by #525, then #540 and #546.
This report is for engineers. The bulletin covers the same issue for users.
Timeline
| Date | Event |
|---|---|
| 19 June 2024 | Commit 171abc2a in #129 creates sign_task.rs and bitcoin_transaction.rs. The input-only owner check and the foreign-only prompt both originate here. |
| 18 July 2024 | #129 merged. |
| 3 August 2026 | Found by internal security review; proof of concept the same day; #525 merged: outputs are validated against the signing key. |
| 19 August 2026 | #540 merged: account and index claims for outputs are bounded. #546 merged: the device prompt shows outputs that return to the user. |
| 25 August 2026 | v0.4.0 released with all three fixes. |
| 27 August 2026 | This report published. |
1 · Background
A signing device’s guarantee is that the transaction on its screen is the transaction it signs. It is the only check that survives a compromised host: the coordinator (the phone or laptop app driving the devices) can lie about anything, and the screen is where the lie must become visible.
Leaving an output off the screen is safe only if something else has established that it does not matter. Change is the usual case.
2 · The two halves
Outputs were not checked. check searched the inputs for an owner whose master_appkey differed from the signing key and rejected the task if it found one. Outputs were not searched. An output could name any appkey.
Local outputs were not shown. user_prompt collected foreign_recipients(), the outputs owned by SpkOwner::Foreign, and nothing else. The widget then paged through them: an amount page and an address page per recipient, a fee page, an optional high-fee warning, and Hold to Sign.
Not displaying change is a defensible choice. Not checking output owners is defensible if outputs cannot affect what the signer agrees to. Each half is safe only if the other holds, and neither did.
3 · Exploitation
The device does not take the signing key on trust: it looks up key_data by KeyId::from_rootkey(device_sign_req.rootkey) in its own store, refuses a key it does not hold, and runs check against that key. Inputs are self-certifying: Input::txout() builds the prevout from the claimed path’s own spk, the sighash commits to it, and a forged input path signs against a prevout that is not on chain. Amounts are covered the same way: a taproot key-spend sighash commits to every prevout value and script, so a forged amount produces a signature that is invalid on chain. Output ownership was the only field in the template that nothing authenticated.
A malicious coordinator could add an output labelled Local under an appkey it controlled. The derived address is spendable only by that appkey’s holder. The output did not appear on the screen, and the fee shown is inputs − outputs, so the hidden output did not inflate it. The user saw their intended recipient and a plausible fee.
The proof of concept ran against the code immediately before the fix (the commit before bcf37647 of #525), with a 100,000 sat input, 10,000 sat to a genuine recipient and 89,000 sat to an output labelled Local under an appkey the signer does not hold:
check() accepted: true
fee shown: 1000 sat
recipients shown: 1
bc1ptj7lqerwtk6w4guc7djl96n6pc75rxm7qvcw888f90w7mjkylx7qcwwhcc 10000 sat
attacker output value: 89000 sat, to bc1p7d2ylu29vuzw77jrelxgrymk26pfj9247ddqd0sexxh42d7svvuqxqyee9
The 89,000 sats appear nowhere on the prompt.
The value available to such an output is everything not going to the displayed recipients and not in the fee. In an ordinary send that is the change, which for a wallet spending one large UTXO is most of the balance.
The extreme case is a transaction with no foreign recipient. At introduction, device/src/bin/v2.rs:359 at 171abc2a (#129) wrote a sentence:
if foreign_recipients.is_empty() {
write!(&mut string, "internal transfer").unwrap();
}
followed by the fee. The screen told the user the transaction was a transfer to themselves.
By the time of the fix, the rewritten widget showed two pages for a recipient-less transaction: the fee, and Hold to Sign. It made no false claim but named nothing. The high-fee warning is computed from the foreign recipients, so with none it never fires.
4 · Exposure in practice
Both coordinator code paths that build an owned output stamp it with the signing wallet’s own key. The change output:
template_tx.push_owned_output(
Amount::from_sat(value),
LocalSpk { master_appkey, bip32_path: /* internal, i */ },
);
and the PSBT output loop do the same. An honest coordinator could not produce an output owned by a different key, so the missing check had nothing to reject, and no test or real transaction could reveal its absence. Reaching the defect takes software built to construct a malicious task. Publishing the fix did not widen the reach: the diff gives an attacker nothing unless they can already run their own software against a user’s device.
5 · The fixes
#525 checks output owners the same way as inputs, tagging the error with kind so the message says which. Three tests: a locally-owned output under a different key is rejected, the same for an input, and a send to an external recipient is still accepted.
#540 bounds what may be claimed. #525 stops a task naming someone else’s key, but not one naming the signer’s own key at a derivation path the wallet will never scan. Such an output is spendable with the signer’s key, but no restored wallet will find it: the deliberate version of what the change-address defect produced by accident. #540 restricts a local output to the default account and an index below a ceiling, and makes a bip32 index prove its range when decoded. It is marked TEMPORARY at the constant and the check, and states its cost: an honest coordinator that allocated past the ceiling would have its task refused.
#546 changes the prompt to show outputs that come back to us, hiding only the single change output of a send that also pays a stranger.
A recipient-less transaction now shows every output. The one output that may still be omitted, the change of a send that also pays a stranger, is omitted only after the device has verified it derives from the signing key within the bounds #540 enforces.
6 · Review by current AI models
The introducing change was put in front of six AI coding agents told nothing about what to look for, in two conditions: the introducing commit alone, and all nine commits of #129. Three of twelve runs reached the defect. One of those also surfaced the internal transfer behaviour and the amounts observation in §3. Kimi K3, the model that assisted the discovery, ran blind from a fresh context and did not find the defect again.
| Reviewer | The introducing commit alone | All nine commits |
|---|---|---|
| Claude Opus 5 | identified | identified |
| Grok 4.6 | identified | missed — cleared it explicitly |
| Codex (GPT-5.6) | missed | missed |
| Kimi K3 | adjacent | missed — audited check() and reported its gaps without this one |
| Qwen3.6-27B (local) | missed | missed — cleared it, approved the branch |
| Qwen3-Coder-Next (local) | missed | missed |
Three agents considered the exact question and cleared it. One recorded that derivation from (root_key, bip32_path) means “a coordinator cannot hide an attacker output as change”, which inverts what derivation does. One stated that SignTask::check “validates ownership” and approved the branch. Kimi K3 audited check() for completeness and listed the gaps it found, duplicate outpoints and zero owned inputs among them, without outputs. All three had the function in front of them. A check that visibly compares an owner’s key against the signing key reads as though it covers ownership, and the reader fills in the outputs from expectation.
These results are one run per condition; the runs and classifications are preserved. None of the six agents existed in July 2024, when this code was written, so the experiment measures what current tools find in earlier code.
A missing check is easy to miss even for a careful reader. What the screen shows can be checked by the person holding the device. That is why #546 matters most.
7 · Origin
171abc2a of #129, “Refactor architecture to better support PSBTs”, created sign_task.rs and bitcoin_transaction.rs in the same commit. The input-only check and the foreign-only prompt were both born there.
A refactor establishes relationships between components, and the invariants that hold them together are what a diff does not show. “An output we do not display is ours” was true while the only coordinator was honest. Nothing rechecked it against a dishonest one.