Monero · Report #3686259
`set_daemon` wallet-rpc silently ignores `ssl_allowed_fingerprints` → pinning bypassed, wallet↔daemon MITM
How it was found
Repository: monero-project/monero — src/wallet/wallet_rpc_server.cpp (shared vulnerable code also reached via contrib/epee/src/net_ssl.cpp). Affected branches: master (commit 230de3794), release-v0.18, release-v0.17 (and every release since the RPC was introduced in commit 67aa4adcf, March 2019).
Summary
The set_daemon JSON-RPC in monero-wallet-rpc accepts an ssl_allowed_fingerprints array and is supposed to pin the daemon's TLS cert to the listed SHA-256 digests. The handler never hex-decodes those strings — it just copies each character into the fingerprint buffer as a byte. A normal 64-char hex fingerprint ends up stored as 64 bytes of ASCII instead of 32 bytes of raw digest. has_fingerprint() later compares the real 32-byte SHA-256 of the peer cert against those 64-byte blobs with std::binary_search, so the check can never succeed. Because the command's default ssl_support is "autodetect", the verify callback turns "nothing matched" into a MWARNING and accepts the connection anyway. Pinning is silently discarded.
The sibling CLI flag --daemon-ssl-allowed-fingerprints on the exact same binary goes through epee::from_hex_locale::to_vector and enforces SSL_FINGERPRINT_SIZE == 32. So the two paths accept the same documented input format and produce opposite security outcomes.
Buggy block, src/wallet/wallet_rpc_server.cpp (current master 230de3794):
std::vector<std::vector<uint8_t>> ssl_allowed_fingerprints;
ssl_allowed_fingerprints.reserve(req.ssl_allowed_fingerprints.size());
for (const std::string &fp: req.ssl_allowed_fingerprints)
{
ssl_allowed_fingerprints.push_back({});
std::vector<uint8_t> &v = ssl_allowed_fingerprints.back();
for (auto c: fp)
v.push_back(c);
}
Correct block, src/wallet/wallet2.cpp (CLI startup):
std::vector<std::vector<uint8_t>> ssl_allowed_fingerprints{ daemon_ssl_allowed_fingerprints.size() };
std::transform(daemon_ssl_allowed_fingerprints.begin(), daemon_ssl_allowed_fingerprints.end(),
ssl_allowed_fingerprints.begin(), epee::from_hex_locale::to_vector);
for (const auto &fpr: ssl_allowed_fingerprints)
{
THROW_WALLET_EXCEPTION_IF(fpr.size() != SSL_FINGERPRINT_SIZE, tools::error::wallet_internal_error,
"SHA-256 fingerprint should be " BOOST_PP_STRINGIZE(SSL_FINGERPRINT_SIZE) " bytes long.");
}
The decisive check in contrib/epee/src/net_ssl.cpp:
if (!verified && !has_fingerprint(ctx))
{
if (support != ssl_support_t::e_ssl_support_autodetect)
{
MERROR("SSL certificate is not in the allowed list, connection dropped");
return false;
}
MWARNING("SSL peer has not been verified");
}
return true;
With autodetect (the command's default in wallet_rpc_server_commands_defs.h: KV_SERIALIZE_OPT(ssl_support, (std::string)"autodetect")) the branch falls through to return true and the daemon's cert is accepted.
Releases Affected
Every release since the set_daemon RPC was added in commit 67aa4adcf (March 2019) — i.e. v0.14.x through v0.18.x and current master @ 230de3794. I checked release-v0.17 and release-v0.18 branches directly; the ASCII-copy block is byte-identical to master. The Ubuntu 22.04 monero package (0.17.2.0) also reproduces.
Steps to Reproduce
I packaged the whole thing as a self-contained script (no sudo needed on Ubuntu 22.04, it uses apt-get download + dpkg-deb -x into /tmp):
./poc_set_daemon_pinning_bypass.sh
Run it with bash ./poc_set_daemon_pinning_bypass.sh.
What it does, briefly:
-
Pulls
monerodandmonero-wallet-rpcfrom the Ubuntumonero.deb into/tmp/monero_extract. -
Generates two distinct self-signed certs,
real.crt(fingerprint$FP_REAL) andevil.crt(fingerprint$FP_EVIL). -
Starts two regtest daemons: real on
127.0.0.1:48080withreal.crt, evil on127.0.0.1:49080withevil.crt. Both in--offlinemode so they cannot talk to each other. -
Starts
monero-wallet-rpc, creates a fresh wallet, and mines 5 blocks on the evil daemon only. Directget_block_countnow returns1on real and6on evil. -
Pins the wallet to
$FP_REALand points it at the real daemon (baseline).refresh+get_heightreturns height 1. Good. -
Calls
set_daemonagain to point the wallet at the evil daemon, keepingssl_allowed_fingerprints=[$FP_REAL]andssl_support="autodetect".refreshreturns:{"result":{"blocks_fetched":5,"received_money":true}}and
get_heightreturns6. The wallet, still "pinned" to the real fingerprint, just absorbed the evil daemon's chain. The only log trace is:WARNING net.ssl contrib/epee/src/net_ssl.cpp SSL peer has not been verifiedNo log line says the fingerprint list was malformed or ignored.
-
For contrast, the script restarts the same binary with the CLI flag instead:
monero-wallet-rpc --daemon-address 127.0.0.1:49080 --daemon-ssl enabled \ --daemon-ssl-allowed-fingerprints "$FP_REAL"refreshnow fails closed:{"error":{"code":-38,"message":"no connection to daemon"}}with
SSL certificate is not in the allowed list, connection droppedin the log. Same binary, same fingerprint, same daemon — the only difference is which decoder the operator's configuration went through.
If you prefer to do it by hand instead of via the script, the core five calls are:
FP_REAL=$(openssl x509 -noout -fingerprint -sha256 -in real.crt \
| sed 's/.*=//' | tr -d ':' | tr '[:upper:]' '[:lower:]')
# baseline
curl -s http://127.0.0.1:48090/json_rpc -H 'Content-Type: application/json' -d "$(cat <<EOF
{"jsonrpc":"2.0","id":"0","method":"set_daemon","params":{
"address":"127.0.0.1:48080","trusted":true,"ssl_support":"autodetect",
"ssl_allowed_fingerprints":["$FP_REAL"]}}
EOF
)"
# bypass
curl -s http://127.0.0.1:48090/json_rpc -H 'Content-Type: application/json' -d "$(cat <<EOF
{"jsonrpc":"2.0","id":"0","method":"set_daemon","params":{
"address":"127.0.0.1:49080","trusted":true,"ssl_support":"autodetect",
"ssl_allowed_fingerprints":["$FP_REAL"]}}
EOF
)"
curl -s http://127.0.0.1:48090/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"refresh"}' -H 'Content-Type: application/json'
curl -s http://127.0.0.1:48090/json_rpc -d '{"jsonrpc":"2.0","id":"0","method":"get_height"}' -H 'Content-Type: application/json'
{F5770401}
Possible Solution
Make the RPC handler do what the CLI path already does:
std::vector<std::vector<uint8_t>> ssl_allowed_fingerprints;
ssl_allowed_fingerprints.reserve(req.ssl_allowed_fingerprints.size());
for (const std::string &fp : req.ssl_allowed_fingerprints)
{
std::vector<uint8_t> decoded;
try { decoded = epee::from_hex_locale::to_vector(fp); }
catch (const std::exception &) {
er.code = WALLET_RPC_ERROR_CODE_NO_DAEMON_CONNECTION;
er.message = "ssl_allowed_fingerprints[] entries must be hex-encoded SHA-256";
return false;
}
if (decoded.size() != SSL_FINGERPRINT_SIZE) {
er.code = WALLET_RPC_ERROR_CODE_NO_DAEMON_CONNECTION;
er.message = "Each fingerprint must decode to exactly 32 bytes";
return false;
}
ssl_allowed_fingerprints.emplace_back(std::move(decoded));
}
A couple of defense-in-depth suggestions while you're there:
- The
ssl_options_t(std::vector<std::vector<uint8_t>>, std::string)constructor incontrib/epee/src/net_ssl.cppshould also reject any entry whose size is notSSL_FINGERPRINT_SIZE. That would catch any future caller that forgets to hex-decode. has_strong_verification()currently only looks at theverificationenum and happily says "strong" for a list of 1000 garbage entries. It should additionally require that every entry haveSSL_FINGERPRINT_SIZEbytes.- When
!fingerprints_.empty()andhas_fingerprint()returns false, log anMERRORthat reports the sizes of the stored entries vs the computed digest. Right now an operator has no way to tell "server cert rotated" from "my pinning list was discarded".
Impact
Anyone who uses set_daemon over RPC to pin their daemon's TLS cert (a standard automation pattern for exchanges, payment processors, and pool operators running wallet-rpc against a remote daemon) gets zero pinning. A network attacker between wallet and daemon can swap in any self-signed cert, complete the handshake and MITM the channel: daemon HTTP digest credentials, /get_outs and key-image-spent queries, decoy-ring responses used during tx construction, signed transactions submitted for relay, /start_mining control payloads. The operator's log shows nothing beyond the same SSL peer has not been verified line that you'd get from any autodetect connection — so you can't even tell from the log that pinning silently died.
Note
The proof-of-concept script in ./reports/poc_set_daemon_pinning_bypass.sh was written with AI assistance to speed up the reproduction scaffolding (cert generation, daemon lifecycle, JSON-RPC plumbing). The vulnerable code path, line references, and manual verification against release-v0.17, release-v0.18, and master @ 230de3794 are mine.
Bounty
If this qualifies for a payout, XMR can be sent to:
45BYNkdWvv45fovzeSgnNdMcZ8Pn9bdnegRyDcggz9XBUbrrgDhwfx63rpvesZMwWEKKQFms81v8hPbCX2eSCb3m9nFk9ZX