fix(legacy): hardening ETH unstake and claim
What changed, and why it matters
This update fixes a security flaw in Trezor's older hardware wallet firmware for Ethereum staking operations. Previously, when a user tried to 'unstake' or 'claim' staked ETH, the device did not reliably block ETH from being sent along with the transaction (msg.value), even though those smart-contract functions are not supposed to accept money. It also did not strictly check that the transaction was on the correct Ethereum network (chain ID), which could have allowed a malicious or accidental transaction on one network to be replayed on another. The fix enforces zero ETH value for unstake/claim and ties each allowed contract address to its proper network.
Users of affected Trezor Model One firmware should upgrade to a release containing this commit. Developers should review whether similar chain_id and msg.value checks are present in other firmware variants (e.g., core) and add regression tests for all non-payable contract interactions.
Security signals we found
Missing chain_id enforcement on contract-address allowlist (replay-risk)
Non-payable unstake/claim paths did not enforce msg.value == 0
Staking transactions with trailing calldata accepted
Changelog fragment labels the change as security-relevant
Evidence from the diff
In legacy/firmware/ethereum.c, isEthereumStakingTx() previously accepted either mainnet or Hoodi testnet pool/accounting addresses without verifying chain_id. The patch adds chain_id checks (CHAIN_ID_MAINNET=1 and CHAIN_ID_HOODI_TESTNET=560048) before matching addresses. For layoutEthereumConfirmStakingTx(), parse_bignum256() on params->value_bytes is now called once at the top instead of only inside the stake branch, so the parsed value is available for all operations. Unstake and claim branches now reject the transaction if value is non-zero, because those contract methods are non-payable. Finally, ethereum_signing_confirm_common() now also rejects staking transactions whose total data_length differs from data_initial_chunk_size, preventing trailing calldata. New UI test fixtures cover bad-value and trailing-data cases.
Changed components
legacy/firmware/ethereum.clegacy/firmware/ethereum.hTrezor Model One (legacy) Ethereum signing flowEthereum staking/unstake/claim confirmation UIInspect captured patch +27 / −6
### legacy/firmware/.changelog.d/+external_eth_unstake_claim.security
@@ -0,0 +1 @@
+Ethereum: Native token transfer blocked for staking operations.
### legacy/firmware/ethereum.c
@@ -657,8 +657,10 @@ static bool isEthereumStakingTx(const struct signing_params *params,
const uint8_t *pubkeyhash = params->pubkeyhash;
const uint8_t *data_chunk = params->data_initial_chunk_bytes;
bool is_address_pool =
- ((memcmp(pubkeyhash, POOL_HOODI_TESTNET, PUBKEYHASH_LEN) == 0) ||
- (memcmp(pubkeyhash, POOL_MAINNET, PUBKEYHASH_LEN) == 0));
+ ((params->chain_id == CHAIN_ID_HOODI_TESTNET &&
+ memcmp(pubkeyhash, POOL_HOODI_TESTNET, PUBKEYHASH_LEN) == 0) ||
+ (params->chain_id == CHAIN_ID_MAINNET &&
+ memcmp(pubkeyhash, POOL_MAINNET, PUBKEYHASH_LEN) == 0));
if (is_address_pool) {
if (memcmp(data_chunk, SC_FUNC_SIG_STAKE, SC_FUNC_SIG_BYTES) == 0) {
*op = ETH_STAKING_STAKE;
@@ -670,8 +672,10 @@ static bool isEthereumStakingTx(const struct signing_params *params,
}
}
bool is_address_accounting =
- ((memcmp(pubkeyhash, ACCOUNTING_HOODI_TESTNET, PUBKEYHASH_LEN) == 0) ||
- (memcmp(pubkeyhash, ACCOUNTING_MAINNET, PUBKEYHASH_LEN) == 0));
+ ((params->chain_id == CHAIN_ID_HOODI_TESTNET &&
+ memcmp(pubkeyhash, ACCOUNTING_HOODI_TESTNET, PUBKEYHASH_LEN) == 0) ||
+ (params->chain_id == CHAIN_ID_MAINNET &&
+ memcmp(pubkeyhash, ACCOUNTING_MAINNET, PUBKEYHASH_LEN) == 0));
if (is_address_accounting) {
if (memcmp(data_chunk, SC_FUNC_SIG_CLAIM, SC_FUNC_SIG_BYTES) == 0) {
*op = ETH_STAKING_CLAIM;
@@ -688,6 +692,7 @@ static bool layoutEthereumConfirmStakingTx(const struct signing_params *params,
params->data_initial_chunk_bytes + SC_FUNC_SIG_BYTES;
bignum256 value = {0};
+ parse_bignum256(params->value_bytes, params->value_size, &value);
struct ethereum_amount amount = {.value = "", .unit = ""};
const char *_line1 = NULL;
const char *_line2 = NULL;
@@ -699,7 +704,6 @@ static bool layoutEthereumConfirmStakingTx(const struct signing_params *params,
if (args_size != SC_ARGUMENT_BYTES) {
return false;
}
- parse_bignum256(params->value_bytes, params->value_size, &value);
ethereumFormatAmount(&value, NULL, /*use_gwei=*/false, &amount);
_line1 = _("Stake");
_line2 = amount.value;
@@ -714,6 +718,10 @@ static bool layoutEthereumConfirmStakingTx(const struct signing_params *params,
if (args_size != 3 * SC_ARGUMENT_BYTES) {
return false;
}
+ // unstake is non-payable, so msg.value must be zero
+ if (!bn_is_zero(&value)) {
+ return false;
+ }
bn_read_be(args_bytes, &value);
ethereumFormatAmount(&value, NULL, /*use_gwei=*/false, &amount);
_line1 = _("Unstake");
@@ -726,6 +734,10 @@ static bool layoutEthereumConfirmStakingTx(const struct signing_params *params,
if (args_size != 0) {
return false;
}
+ // claim is non-payable, so msg.value must be zero
+ if (!bn_is_zero(&value)) {
+ return false;
+ }
_line1 = _("Claim ETH");
_line2 = _("from Everstake?");
break;
@@ -741,7 +753,8 @@ static bool ethereum_signing_confirm_common(
const struct signing_params *params) {
enum staking_operation_t staking_op;
if (isEthereumStakingTx(params, &staking_op)) {
- if (!layoutEthereumConfirmStakingTx(params, staking_op)) {
+ if ((params->data_length != params->data_initial_chunk_size) ||
+ !layoutEthereumConfirmStakingTx(params, staking_op)) {
fsm_sendFailure(FailureType_Failure_DataError,
_("Invalid staking transaction call"));
return false;
### legacy/firmware/ethereum.h
@@ -27,6 +27,8 @@
#include "messages-ethereum.pb.h"
#define CHAIN_ID_UNKNOWN UINT64_MAX
+#define CHAIN_ID_MAINNET 1
+#define CHAIN_ID_HOODI_TESTNET 560048
void ethereum_signing_init(const EthereumSignTx *msg, const HDNode *node,
const EthereumDefinitionsDecoded *defs);
### tests/ui_tests/fixtures.json
@@ -566,8 +566,13 @@
"T1B1_en_ethereum-test_signtx.py::test_signtx_eip1559_access_list[max_count]": "de54dbfe5e5bc666b896f13d4fbfbb0253f5638c084831f57ec49148136427f1",
"T1B1_en_ethereum-test_signtx.py::test_signtx_eip1559_access_list[single_entry]": "de54dbfe5e5bc666b896f13d4fbfbb0253f5638c084831f57ec49148136427f1",
"T1B1_en_ethereum-test_signtx.py::test_signtx_error[claim_bad_inputs]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_ethereum-test_signtx.py::test_signtx_error[claim_main_trailing_data]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_ethereum-test_signtx.py::test_signtx_error[claim_with_value]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_ethereum-test_signtx.py::test_signtx_error[stake_bad_inputs]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_ethereum-test_signtx.py::test_signtx_error[stake_main_trailing_data]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_ethereum-test_signtx.py::test_signtx_error[unstake_bad_inputs]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_ethereum-test_signtx.py::test_signtx_error[unstake_main_trailing_data]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+"T1B1_en_ethereum-test_signtx.py::test_signtx_error[unstake_with_value]": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"T1B1_en_ethereum-test_signtx.py::test_signtx_staking[False-claim_mainnet]": "3f5ecfcf8574786f1bd78eaf10fa17091dd4f31fa6efc0a2d7ade1f396947286",
"T1B1_en_ethereum-test_signtx.py::test_signtx_staking[False-hoodi]": "3f5ecfcf8574786f1bd78eaf10fa17091dd4f31fa6efc0a2d7ade1f396947286",
"T1B1_en_ethereum-test_signtx.py::test_signtx_staking[False-stake_hoodi]": "a63adc090f27ddb199f9f9575c859e44f8d0cb90e70fe628d5eb4fc2a7190096",Why this scored 70/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.