Monero · Report #3686283
View-only offline transaction creation bypasses the long-payment-ID privacy block
How it was found
Summary
The monero-gui Transfer page correctly blocks the normal online "Send" action when a standalone payment ID is present. The warning shown to the user states that long payment IDs are obsolete, were not encrypted on-chain, and harm privacy.
However, the view-only offline transaction signing flow does not enforce the same protection. In a view-only wallet, the "Offline transaction signing" -> "Create" button remains enabled even when the long-payment-ID warning is visible. That offline Create action forwards the same paymentIdLine.text value into the shared transaction creation handler and then into the C++ wallet backend transaction creation API.
This means a QR code, monero: URI, or manually entered payment request can include a standalone payment ID that blocks normal Send, but still allows creation of an unsigned offline transaction. If the unsigned transaction is later signed and submitted, the unencrypted payment ID can link or identify the user's payment on-chain.
Weakness
Protection Mechanism Failure, CWE-693.
A secondary classification may be CWE-200 because the impact is privacy exposure through transaction metadata.
Severity
Suggested CVSS 3.0:
CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
Suggested severity: Medium.
Affected Component
monero-gui transaction creation flow:
pages/Transfer.qmlmain.qmlcomponents/QRCodeScanner.qmlcomponents/TxConfirmationDialog.qmlsrc/libwalletqt/Wallet.cppsrc/libwalletqt/WalletManager.cpp
The exact release version is not inferable from my local checkout because it does not include Git metadata. The issue is present in the reviewed monero-gui source tree.
Technical Details
Untrusted payment data can enter the GUI through a monero: URI or QR code.
main.qml parses Monero URI/deeplink input and forwards the parsed payment ID into the transfer view:
// main.qml:447-460
function onUriHandler(uri){
if (uri && (uri.startsWith("monero://") || uri.startsWith("monero:"))) {
const normalizedUri = uri.replace("monero://", "monero:");
const parsed = walletManager.parse_uri_to_object(normalizedUri);
if (parsed.error) {
console.log("Invalid Monero URI: " + parsed.error);
} else {
middlePanel.transferView.sendTo(
parsed.address || "",
parsed.payment_id || "",
parsed.tx_description || "",
parsed.amount || ""
);
QR payloads follow the same trust boundary:
// components/QRCodeScanner.qml:88-94
onDecoded : {
const parsed = walletManager.parse_uri_to_object(data);
if (!parsed.error) {
root.qrcode_decoded(parsed.address, parsed.payment_id, parsed.amount, parsed.tx_description, parsed.recipient_name, parsed.extra_parameters);
root.state = "Stopped";
} else if (walletManager.addressValid(data, appWindow.persistentSettings.nettype)) {
root.qrcode_decoded(data, "", "", "", "", null);
The Transfer page stores the supplied payment ID in UI state:
// pages/Transfer.qml:95-105
function fillPaymentDetails(address, payment_id, amount, tx_description, recipient_name) {
...
recipientModel.newRecipient(address, Utils.removeTrailingZeros(amount || ""));
setPaymentId(payment_id || "");
setDescription((recipient_name ? recipient_name + (tx_description ? " (" + tx_description + ")" : "") : (tx_description || "")));
}
// pages/Transfer.qml:119-122
function setPaymentId(value) {
paymentIdLine.text = value;
paymentIdCheckbox.checked = paymentIdLine.text != "";
}
The GUI correctly recognizes this state as dangerous and warns that long payment IDs harm privacy:
// pages/Transfer.qml:817-823
MoneroComponents.WarningBox {
id: paymentIdWarningBox
text: qsTr("Long payment IDs are obsolete. \
Long payment IDs were not encrypted on the blockchain and would harm your privacy. \
If the party you're sending to still requires a long payment ID, please notify them.") + translationManager.emptyString;
visible: paymentIdCheckbox.checked || warningLongPidDescription
}
The normal online Send button is disabled when that warning is visible:
// pages/Transfer.qml:831-843
StandardButton {
id: sendButton
...
text: qsTr("Send") + translationManager.emptyString
enabled: !sendButtonWarningBox.visible && !warningContent && !recipientModel.hasEmptyAddress() && !paymentIdWarningBox.visible
onClicked: {
...
setPaymentId(paymentIdLine.text.trim());
root.paymentClicked(recipientModel.getRecipients(), paymentIdLine.text, root.mixin, priority, descriptionLine.text)
}
}
But the view-only offline transaction "Create" button omits !paymentIdWarningBox.visible:
// pages/Transfer.qml:933-944
AdvancedOptionsItem {
visible: persistentSettings.transferShowAdvanced && appWindow.walletMode >= 2
title: qsTr("Offline transaction signing") + translationManager.emptyString
button1.text: qsTr("Create") + translationManager.emptyString
button1.enabled: appWindow.viewOnly && pageRoot.checkInformation() && appWindow.daemonSynced
button1.onClicked: {
console.log("Transfer: saveTx Clicked")
var priority = priorityModelV5.get(priorityDropdown.currentIndex).priority
console.log("priority: " + priority)
setPaymentId(paymentIdLine.text.trim());
root.paymentClicked(recipientModel.getRecipients(), paymentIdLine.text, root.mixin, priority, descriptionLine.text)
}
The shared transaction handler then forwards the payment ID into wallet transaction creation:
// main.qml:938-970
function handlePayment(recipients, paymentId, mixinCount, priority, description, createFile) {
...
if (recipientAll) {
currentWallet.createTransactionAllAsync(recipientAll.address, paymentId, mixinCount, priority);
} else {
const addresses = recipients.map(function (recipient) {
return recipient.address;
});
const amountsxmr = recipients.map(function (recipient) {
return recipient.amount;
});
currentWallet.createTransactionAsync(addresses, paymentId, amountsxmr, mixinCount, priority);
}
}
The C++ wrapper preserves and forwards the payment ID to the wallet backend:
// src/libwalletqt/Wallet.cpp:631-655
PendingTransaction *Wallet::createTransaction(
const QVector<QString> &destinationAddresses,
const QString &payment_id,
const QVector<QString> &destinationAmounts,
quint32 mixin_count,
PendingTransaction::Priority priority)
{
...
Monero::PendingTransaction *ptImpl = m_walletImpl->createTransactionMultDest(
destinations,
payment_id.toStdString(),
amounts,
mixin_count,
static_cast<Monero::PendingTransaction::Priority>(priority),
currentSubaddressAccount(),
subaddr_indices);
The async wrapper also preserves the value:
// src/libwalletqt/Wallet.cpp:659-668
void Wallet::createTransactionAsync(
const QVector<QString> &destinationAddresses,
const QString &payment_id,
const QVector<QString> &destinationAmounts,
quint32 mixin_count,
PendingTransaction::Priority priority)
{
m_scheduler.run([this, destinationAddresses, payment_id, destinationAmounts, mixin_count, priority] {
PendingTransaction *tx = createTransaction(destinationAddresses, payment_id, destinationAmounts, mixin_count, priority);
emit transactionCreated(tx, destinationAddresses, payment_id, mixin_count);
});
}
The transaction confirmation dialog shows recipient information, but does not show the standalone payment ID:
// components/TxConfirmationDialog.qml:302-322
text: {
return recipients.map(function (recipient, index) {
...
const spacedaddress = recipient.address.match(/.{1,4}/g).join(' ');
return title + "<br>" + spacedaddress;
}).join("<br><br>");
}
Steps to Reproduce (theory)
These steps can be performed with a dummy wallet in a local-only test environment. No real funds, real wallet seed, production wallet, or public node access is required.
- Open monero-gui with a dummy view-only wallet.
- Enable advanced transfer options.
- Ensure the wallet reaches the state where offline transaction creation is available.
- Provide a Monero URI or QR payload containing:
- a valid dummy recipient address,
- a dummy amount,
- a standalone 64-hex payment ID.
- Observe that the Transfer page stores the payment ID in
paymentIdLine.text. - Observe that the long-payment-ID warning is visible.
- Observe that the normal Send button is disabled because its enabled condition includes
!paymentIdWarningBox.visible. - Observe that the view-only "Offline transaction signing" -> "Create" button remains enabled because its enabled condition is only:
appWindow.viewOnly && pageRoot.checkInformation() && appWindow.daemonSynced
- Click "Create" in the dummy environment.
- The payment ID is forwarded through:
pages/Transfer.qml:942-943main.qml:938-970src/libwalletqt/Wallet.cpp:659-668src/libwalletqt/Wallet.cpp:631-655
Observed Result
The normal Send path blocks transaction creation when the long-payment-ID warning is visible, but the view-only offline Create path remains enabled and forwards the same payment ID to the wallet backend transaction creation sink.
Expected Result
Offline transaction creation should enforce the same payment ID privacy protection as normal Send. If the GUI blocks normal Send because a standalone long payment ID harms privacy, it should also block view-only offline transaction creation with the same payment ID.
Why this is security-relevant
This is not only a UI inconsistency. The application explicitly says that long payment IDs were not encrypted on-chain and harm privacy. The normal Send flow enforces that policy, but the offline transaction creation flow bypasses it and passes the payment ID into backend transaction creation.
For users relying on cold-signing or view-only wallet workflows, the view-only machine prepares the unsigned transaction. The signing device/user may reasonably trust that monero-gui enforced the same privacy constraints during offline transaction creation as it does during normal sending.
AI Usage Acknowledgement
ChatGPT 5.4 helped create this report and helped with the source code scan. The analysis was based on authorized local source-code review of the monero-gui repository, and the report claims are grounded in the code references listed above.
## Impact
An attacker, merchant, or compromised payment request generator can supply a `monero:` URI or QR code containing a standalone payment ID. In the normal Send flow, monero-gui blocks transaction creation because the app recognizes that long payment IDs are unencrypted on-chain and harm privacy.
In the view-only offline signing workflow, the same payment ID can still be used to create an unsigned transaction. If the user later signs and submits that transaction, the standalone payment ID can link or identify the user's payment on-chain.
The practical impact is a privacy-protection bypass in a financial privacy wallet:
- transaction metadata that the normal GUI blocks can still enter the offline transaction creation path;
- users can unknowingly prepare unsigned transactions containing unencrypted linkable metadata;
- the confirmation dialog does not clearly display the standalone payment ID before creation;
- cold-signing/view-only workflows are affected, which are used by security-conscious users who expect stricter transaction handling.
This does not bypass wallet signing and does not automatically steal funds, so I am rating it as Medium rather than High. The issue is still security-relevant because it bypasses an explicit Monero privacy protection and can result in on-chain linkability if the prepared transaction is signed and broadcast.