Monero · Report #3693636
wallet-rpc crash via malformed /gettransactions response (empty txs → vector::front() in check_tx_key / check_tx_proof)
How it was found
Repository: monero-project/monero — src/wallet/wallet2.cpp (same response type COMMAND_RPC_GET_TRANSACTIONS in src/rpc/core_rpc_server_commands_defs.h). Affected branch: master (commit 474657ad4, 2026-04-24).
Summary
wallet2 calls the daemon HTTP endpoint /gettransactions and treats the answer as trustworthy enough to drive payment verification. The guard only insists that either the structured array txs or the legacy array txs_as_hex has length 1:
THROW_WALLET_EXCEPTION_IF(!ok || (res.txs.size() != 1 && res.txs_as_hex.size() != 1),
error::wallet_internal_error, "Failed to get transaction from daemon");
If the daemon (or anything on the HTTP path) returns status: OK, txs: [], and a single blob in txs_as_hex whose hash matches the requested txid, the code takes the else branch, parses the transaction from hex, runs the crypto checks, then still pulls pool height and confirmation metadata from res.txs.front() — even though txs was empty the whole time. That is undefined behaviour in C++ (std::vector::front() on an empty vector). On a normal Release build you usually get an immediate segfault; with ASan you get a clean vector::front report.
The same pattern exists in check_tx_proof after the identical guard and if (res.txs.size() == 1) / else split.
There is a second, narrower footgun on the cold hardware get_tx_key path: after the same (txs XOR txs_as_hex) guard, the code always loads the blob from res.txs_as_hex.front() (see snippet below) and never branches on res.txs.size() == 1 first. A response with txs populated and txs_as_hex empty passes the guard when txs.size()==1, but then txs_as_hex.front() is the one that blows up. That is a different corner case from the main check_tx_key crash, same root cause class: the wallet assumes the two representations stay in sync.
check_tx_key_helper (txid overload), current master:
void wallet2::check_tx_key_helper(const crypto::hash &txid, const crypto::key_derivation &derivation, ...)
{
...
ok = epee::net_utils::invoke_http_json("/gettransactions", req, res, *m_http_client);
THROW_WALLET_EXCEPTION_IF(!ok || (res.txs.size() != 1 && res.txs_as_hex.size() != 1),
error::wallet_internal_error, "Failed to get transaction from daemon");
if (res.txs.size() == 1)
ok = get_pruned_tx(res.txs.front(), tx, tx_hash);
else {
ok = string_tools::parse_hexstr_to_binbuff(res.txs_as_hex.front(), tx_data);
...
tx_hash = cryptonote::get_transaction_hash(tx);
}
...
check_tx_key_helper(tx, derivation, additional_derivations, address, received);
in_pool = res.txs.front().in_pool; // ← txs may still be empty
...
}
check_tx_proof (txid overload), same file — same bug after check_tx_proof(tx, ...):
if (!check_tx_proof(tx, address, is_subaddress, message, sig_str, received))
return false;
in_pool = res.txs.front().in_pool;
...
if (err.empty())
confirmations = bc_height - res.txs.front().block_height;
Cold get_tx_key when tx_prefix_hash has to be filled from the daemon:
THROW_WALLET_EXCEPTION_IF(!ok || (res.txs.size() != 1 && res.txs_as_hex.size() != 1),
error::wallet_internal_error, "Failed to get transaction from daemon");
...
bool ok = string_tools::parse_hexstr_to_binbuff(res.txs_as_hex.front(), tx_data);
This is not the same issue as ./reports/critical-wallet-check-tx-proof-is-out-to-acc-oob-and-uninit-derivation.md (proof math / is_out_to_acc / derivation). That report is about bogus proof content; here the wallet dies on bogus envelope shape before you get a clean error string.
Releases Affected
master @ 474657ad4 (2026-04-24) still contains the snippets above. The guard and the front() uses have been this way for a long time; any release that ships the current wallet2 /gettransactions integration is exposed. Severity for bounty triage is usually capped where the attacker already sits on the wallet→daemon HTTP leg (malicious remote node, MITM on cleartext HTTP, broken reverse proxy). There is no RCE claim here — process death / UB only.
Steps to Reproduce
Prerequisites — build once (same dependency set as the rest of the Monero tree; roughly an hour on a typical Linux box):
sudo apt-get install -y build-essential cmake pkg-config libssl-dev \
libzmq3-dev libunbound-dev libminiupnpc-dev libboost-all-dev \
libhidapi-dev libusb-1.0-0-dev
export MONERO_SRC="$HOME/src/monero" # or wherever you cloned github.com/monero-project/monero
cd "$MONERO_SRC"
git checkout master && git pull --ff-only origin master
mkdir -p build/release && cd build/release
cmake -D CMAKE_BUILD_TYPE=Release -D BUILD_TESTS=OFF ../..
make -j"$(nproc)" monerod monero-wallet-rpc
# Binaries: $MONERO_SRC/build/release/bin/monerod
# $MONERO_SRC/build/release/bin/monero-wallet-rpc
Copy (or symlink) the two PoC files from your checkout root into the same tree if they are not already there:
./reports/poc_proxy_malicious_gettransactions.py./reports/poc_gettransactions_empty_txs_crash.sh
The shell script looks for monerod / monero-wallet-rpc in build/release/bin under the repo that contains the reports/ directory, or you can point it anywhere with:
export MONERO_BIN="$MONERO_SRC/build/release/bin"
Run (~1–2 min, regtest only, no public network):
cd "$MONERO_SRC" # repo root that contains ./reports/
bash ./reports/poc_gettransactions_empty_txs_crash.sh
What it does, in order:
- Starts
monerod --regtest --fixed-difficulty 1on a loopback RPC port (default38081in the script). - Starts
monero-wallet-rpc,create_wallet, mines a short chain, does a self-transferwithget_tx_keyso we have a realtxid/tx_key/ address triplet. - Calls the real daemon
/gettransactionswithprune:false,split:falseand pulls the full tx hex blob (what a honest node would return intxs_as_hex). - Starts the Python MITM on
127.0.0.1:47147(override withPROXY_PORTif that port is busy). The proxy forwards everything to the realmonerodexcept: forPOST /gettransactionsbodies that list the target txid intxs_hashes, it returns HTTP 200 JSON withstatus:"OK",txs:[],txs_as_hex:[<captured hex>],missed_tx:[]. - Restarts
monero-wallet-rpcwith--daemon-address http://127.0.0.1:$PROXY_PORTand--daemon-ssl disabled, reloads the same wallet, then issuesset_daemon(same URL, ssl disabled) so the HTTP client does a clean reconnect after the heavy refresh — otherwise the epee client occasionally thinks it is still connected when the TCP session is already half-dead and the PoC fails before the hijacked body is applied. - Calls
check_tx_keyover wallet JSON-RPC. The wallet parses the blob, matchestx_hash == txid, then hitsres.txs.front().
Expected output (abridged):
monero-wallet-rpcdies with signal 11 (Segmentation fault) on thecheck_tx_keycall.- The curl client shows
curl: (52) Empty reply from serverbecause the RPC worker process is gone mid-request. - Proxy log shows one
POST /gettransactions200and a stderr line similar to:[poc-proxy] HIJACK /gettransactions → txs=[], txs_as_hex=len=<N>
{F5794759}
{F5794760}
Lab-only transport detail (not part of the security bug): the PoC proxy adds Connection: close on every response. Without that, Python's default HTTP server behaviour combined with epee's post-response handling can leave the wallet thinking the socket is still up after the server closed it, so the next /gettransactions fails before you reach the malicious branch. Production MITMs usually speak proper HTTP/1.1; this is scaffolding hygiene.
If you prefer not to use the script, the shape you need is:
- Honest
/gettransactionsonce to capture the canonical hex for txidT. - MITM wallet→daemon HTTP; on
POST /gettransactionswhen the JSON body contains"txs_hashes":["<T>..."], respond with{"status":"OK","untrusted":false,"credits":0,"top_hash":"","txs":[],"txs_as_hex":["<hex>"],"missed_tx":[]}(plusConnection: closeif your server drops the socket aggressively). monero-wallet-rpc→check_tx_keywith thatTand the realtx_key/ address from the wallet that created the tx.
Possible Solution
-
After
/gettransactions, if you needin_pool,block_height, orconfirmationsfromCOMMAND_RPC_GET_TRANSACTIONS::entry, requireres.txs.size() == 1and treat any other combination aswallet_internal_errorwith a clear message — or re-query the daemon using a response contract that always includes structured metadata. -
Split metadata extraction to mirror the branch that actually supplied the blob (
txs.front()vs synthesizing defaults when only legacy hex is present). -
Cold
get_tx_key: mirror theif (res.txs.size()==1) get_pruned_tx else parse txs_as_hexstructure; do not assumetxs_as_hexis non-empty whenevertxshas an entry. -
Add deserialization / integration tests for
(txs empty, txs_as_hex len 1)and(txs len 1, txs_as_hex empty)so this cannot regress silently. Issue #8311 is a good reminder that real daemons have returned odd shapes before; the wallet should fail closed, not UB.
Impact
Anyone who points a wallet at a node they do not fully trust over cleartext HTTP, or who sits behind a misconfigured TLS-terminating proxy, can have check_tx_key or check_tx_proof turned into a hard crash of monero-wallet-rpc (or the CLI wallet process) with a single crafted /gettransactions response. That is a straight availability hit on payment verification and any automation that wraps these RPCs.
No remote code execution is demonstrated; the failure mode observed in testing is SIGSEGV on vector::front.
Note
The end-to-end shell + Python MITM (poc_gettransactions_empty_txs_crash.sh, poc_proxy_malicious_gettransactions.py) was put together with editor/AI help for the regtest plumbing and the HTTP keep-alive workaround. The vulnerable control flow and line references were checked against master by reading wallet2.cpp directly.
Bounty
If this qualifies for a payout, XMR can be sent to:
47TUANy82gtBFMvsFwyA3hE98PzgKSmxNia3n9fpvSadDWs234M7oWfevXqYxKmy3d9hSp1TH82gX4JWvNEUupse2s92maV