#37545 [BC-Medium] Deposits with a lock_time of 16 cannot be processed
Submitted on Dec 8th 2024 at 07:54:54 UTC by @PaiMei_and_Gandalf for Attackathon | Stacks
Report ID: #37545
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/stacks-network/sbtc/tree/immunefi_attackaton_0.9/sbtc
Impacts:
Direct loss of funds
Description
Brief/Intro
Reclaim scripts that want a lock_time of 16 blocks must be created as the opcode OP_PUSHNUM_16
followed by opcode OP_CSV
and followed by the rest of the reclaim script (whatever it may be). It turns out trying to parse a reclaim script with this format throws an error, and deposits with this format cannot be processed by sBTC
.
Vulnerability Details
The parsing of a reclaim script in file sbtc/src/deposits.rs
matches the script with three patterns, which are the only ones valid for the sBTC
project.
pub fn parse(reclaim_script: &ScriptBuf) -> Result<Self, Error> {
let (lock_time, script) = match reclaim_script.as_bytes() {
// These first two branches check for the case when the script
// is written with as few bytes as possible (called minimal
// CScriptNum format or something like that).
[0, OP_CSV, script @ ..] => (0, script),
// This catches numbers 1-16 and -1. Negative numbers are
// invalid for OP_CHECKSEQUENCEVERIFY, but we filter them out
// later in `ReclaimScriptInputs::try_new`.
[n, OP_CSV, script @ ..]
if OP_PUSHNUM_NEG1 == *n || (OP_PUSHNUM_1..OP_PUSHNUM_16).contains(n) =>
{
(*n as i64 - OP_PUSHNUM_1 as i64 + 1, script)
}
// Numbers in bitcoin script are typically only 4 bytes (with a
// range from -2**31+1 to 2**31-1), unless we are working with
// OP_CSV or OP_CLTV, where 5-byte numbers are acceptable (with
// a range of 0 to 2**39-1). See the following for how the code
// works in bitcoin-core:
// https://github.com/bitcoin/bitcoin/blob/v27.1/src/script/interpreter.cpp#L531-L573
// That said, we only accepts 4-byte unsigned integers, and we
// check that below.
[n, rest @ ..] if *n <= 5 && rest.get(*n as usize) == Some(&OP_CSV) => {
// We know the error and panic paths cannot happen because
// of the above `if` check.
let (script_num, [OP_CSV, script @ ..]) = rest.split_at(*n as usize) else {
return Err(Error::InvalidReclaimScript);
};
(read_scriptint(script_num, 5)?, script)
}
_ => return Err(Error::InvalidReclaimScript),
};
let lock_time =
u32::try_from(lock_time).map_err(|_| Error::InvalidReclaimScriptLockTime(lock_time))?;
let script = ScriptBuf::from_bytes(script.to_vec());
ReclaimScriptInputs::try_new(lock_time, script)
}
We can see in the second branch of the match
statement, this comment:
// This catches numbers 1-16 and -1
...
[n, OP_CSV, script @ ..]
if OP_PUSHNUM_NEG1 == *n || (OP_PUSHNUM_1..OP_PUSHNUM_16).contains(n) =>
{
(*n as i64 - OP_PUSHNUM_1 as i64 + 1, script)
}
...
The problem here is OP_PUSHNUM_1..OP_PUSHNUM_16
does not include OP_PUSHNUM_16
in the range, so trying to parse a script with this format will throw an InvalidReclaimScript
error.
The fix for this bug is to explicitly include the last element in the range.
...
[n, OP_CSV, script @ ..]
- if OP_PUSHNUM_NEG1 == *n || (OP_PUSHNUM_1..OP_PUSHNUM_16).contains(n) =>
+ if OP_PUSHNUM_NEG1 == *n || (OP_PUSHNUM_1..=OP_PUSHNUM_16).contains(n) =>
{
(*n as i64 - OP_PUSHNUM_1 as i64 + 1, script)
}
...
The fact that using a non minimal reclaim script to try to accomplish the same result (having a lock_time
of 16 blocks) would parse correctly but would recreate a different reclaim script than the one parsed, would make the function validate_tx
also throw an error when trying to validate the transaction:
pub fn validate_tx(&self, tx: &Transaction) -> Result<DepositInfo, Error> {
...
// Validate that the deposit and reclaim scripts in the request
// match the expected formats for deposit transactions.
let deposit = DepositScriptInputs::parse(&self.deposit_script)?;
let reclaim = ReclaimScriptInputs::parse(&self.reclaim_script)?;
// Okay, the deposit and reclaim scripts are valid. Now make sure
// that the ScriptPubKey in the transaction matches the one implied
// by the given scripts. So now create the expected ScriptPubKey.
let deposit_script = deposit.deposit_script();
let reclaim_script = reclaim.reclaim_script();
debug_assert_eq!(deposit_script, self.deposit_script);
debug_assert_eq!(reclaim_script, self.reclaim_script);
let expected_script_pubkey =
to_script_pubkey(deposit_script.clone(), reclaim_script.clone());
// Check that the expected scriptPubkey matches the actual public
// key of our parsed UTXO.
if expected_script_pubkey != tx_out.script_pubkey {
return Err(Error::UtxoScriptPubKeyMismatch(self.outpoint));
}
...
}
Because &self.reclaim_script
and let reclaim_script = reclaim.reclaim_script();
will end up being different, and then expected_script_pubkey
will be different than tx_out.script_pubkey
and the validation will throw a UtxoScriptPubKeyMismatch
error.
In conclusion, no script with a lock_time
of 16 can be used currently in sBTC
.
Impact Details
The impact is High because users will lose the fees of the Bitcoin deposit, and the later reclaim of funds for using a valid reclaim script that sBTC simply does not recognize as valid. Also, their funds will be unavailable for them to use for 16 blocks.
References
https://github.com/stacks-network/sbtc/blob/immunefi_attackaton_0.9/sbtc/src/deposits.rs#L446-L485 https://github.com/stacks-network/sbtc/blob/immunefi_attackaton_0.9/sbtc/src/deposits.rs#L126-L172
Proof of Concept
Proof of Concept
We added the following unit test to file sbtc/src/deposits.rs
.
#[test]
fn OP_PUSHNUM_16_disallowed() {
let lock_time = 16;
// The first script is simply the OP_PUSHNUM_16 opcode followed by the OP_CSV opcode
// It should be parsed correctly, but instead throws an error
let reclaim_script = ScriptBuf::from_bytes(vec![0x60, 0xB2]);
let error = ReclaimScriptInputs::parse(&reclaim_script).unwrap_err();
assert!(matches!(error, Error::InvalidReclaimScript));
// The second script is 1, followed by the number 16 and followed by the OP_CSV opcode
// This is a non minimal representation that produces the same script, a lock_time of 16
// In this case the script is parsed correctly, but when reconstructed it is not equal
// to reclaim_script2, but to reclaim_script (the one that uses OP_PUSHNUM_16)
let reclaim_script2 = ScriptBuf::from_bytes(vec![0x01, 0x10, 0xB2]);
let extracts = ReclaimScriptInputs::parse(&reclaim_script2).unwrap();
assert_ne!(extracts.reclaim_script(), reclaim_script2);
assert_eq!(extracts.reclaim_script(), reclaim_script);
}
Last updated
Was this helpful?