XRP Ledger Payment Engine Overflow
Overview
This post analyzes the Payment Engine XRP Overflow, one of the issues covered in the xrpld 3.4.1 vulnerability disclosure report published on the XRPL blog on October 9, 2026. Since the source code was released together with the report, this post compares the 3.4.0 and 3.4.1 code to outline the root cause and the patch.
In short, the bug occurred because the payment engine did not check for overflow when adding up the XRP amounts of multiple offers. If the total exceeds the int64 range and wraps around to a small value, the buyer pays only that small amount, while the sellers receive their original amounts in full. This vulnerability was a money-duplication bug that caused new XRP to be created in the amount of that difference.
| Item |
Details |
| Affected versions |
xrpld 3.4.0 and earlier |
| Fixed version |
xrpld 3.4.1 (2026-09-25) |
| Type |
Integer Overflow (CWE-190) |
| Severity |
Critical |
| CVE |
None |
| Reported by |
Cayden Liao, Veria AI |
The vulnerability was discovered by AI security researcher Cayden Liao, and no evidence of exploitation on a live network has been found. Vulnerability Disclosure Report for xrpld 3.4.1
Background
This section briefly summarizes only what is needed for the analysis.
On the XRPL, XRP amounts are handled as integers in units of drops. One XRP equals 1,000,000 drops, and in the code, the XRPAmount type holds this value as a single int64. The addition operator of this type simply performs drops_ += other.drops() and has no range check.
The total XRP supply is 100 billion XRP (10^17 drops), and the maximum int64 value is approximately 9.2 × 10^18, so a single account balance cannot overflow. When multiple amounts are summed, however, just 93 amounts of 10^17 drops each are enough to exceed the int64 range.
On the payment side, when a Payment transaction that buys tokens with XRP is submitted, the payment engine consumes the sell offers on the order book, starting with the best price. Order book processing is handled by a class called BookStep, and this is where the bug occurs.
Vulnerability Analysis
The part of BookStep::revImp() that processes offers one at a time is as follows.
// src/libxrpl/tx/paths/BookStep.cpp (3.4.0)
if (stpAmt.out <= remainingOut)
{
savedIns.insert(stpAmt.in);
savedOuts.insert(stpAmt.out);
result = TAmounts<TIn, TOut>(sum(savedIns), sum(savedOuts));
remainingOut = out - result.out;
this->consumeOffer(sb, offer, ofrAmt, stpAmt, ownerGives);
return true;
}
For each offer, the amount the buyer must pay (stpAmt.in) is collected in savedIns, and the running total is recalculated with sum(savedIns). This total later becomes the amount debited from the buyer's account. Payment to the sellers, by contrast, is handled separately for each offer in consumeOffer().
As shown below, sum() calls std::accumulate directly.
template <class TCollection>
static auto
sum(TCollection const& col)
{
using TResult = std::decay_t<decltype(*col.begin())>;
if (col.empty())
return TResult{beast::kZero};
return std::accumulate(col.begin() + 1, col.end(), *col.begin());
};
Because std::accumulate uses the XRPAmount addition described above as is, a total that exceeds the int64 range is not blocked; instead, it wraps around modulo 2^64 and becomes a small value.
In summary, the buyer pays only the wrapped total, while each seller receives the full amount set in their offer. According to the report, the amount actually charged to the buyer was a few hundred drops plus fees.
Attack Conditions
The attack method described in the report is as follows:
- Several hundred accounts each place an offer to sell a very small amount of a token for a very large amount of XRP
- Another account consumes all of these offers in a single payment
The cost was a few hundred XRP in reserves tied to the accounts and offers (recoverable later) plus fees, and the attack was possible with ordinary user privileges. RippleX reportedly reproduced it on a standalone server and confirmed that the newly created XRP could actually be spent.
Such offers are priced so unfavorably that they sit at the very bottom of the order book. Because ordinary payments are filled by other offers before reaching them, there was no chance of this vulnerability being triggered accidentally during normal trading. The report likewise judged that using these offers to interfere with other users' payments was not realistic.
Invariant Check Bypass
The XRPL has invariant checks that run after a transaction is processed and before its results are applied to the ledger. Among them, XRPNotCreated is the check that verifies no new XRP has been created, but it failed to catch this bug.
XRPNotCreated accumulates the XRP changes of entries such as accounts and escrows into a single int64 variable, then checks whether the total reflects a decrease equal to the fee. However, this accumulation also uses the same int64 addition, so it wraps in the same way. When the buyer-side and seller-side changes are added together, the result comes out to -(fee), and the check treats the transaction as valid.
There is also XRPBalanceChecks, which fails when a single account's balance exceeds 100 billion XRP, but it is not triggered if the receiving side is split across several hundred accounts. For the total to wrap around to a small value, the actual sum must be near 2^64 drops (approximately 18.45 trillion XRP), so even if each account receives 100 billion XRP, at least 185 accounts are required. This is why the report states that several hundred accounts are needed, and the reporter's estimate of approximately 18 trillion XRP per payment is also consistent with this figure. The Block
According to the report, this invariant was added two years after the payment engine and used the same int64 addition from the start.
Patch Analysis
First, sum() in BookStep was changed to check for overflow on every addition.
- return std::accumulate(col.begin() + 1, col.end(), *col.begin());
+ return std::accumulate(
+ col.begin() + 1, col.end(), *col.begin(), [](TResult const& a, TResult const& b) {
+ return checkedStepAdd(a, b);
+ });
checkedStepAdd() checks, before adding, whether the result would exceed the int64 range and, if it would, throws an exception with tecPATH_DRY. Because this exception is handled at the level of each payment path, an attack payment ends in an ordinary path dry (or partial payment) failure, and payments that do not overflow produce the same results as before. The same check was also added to StrandFlow.h, which combines the results of multiple payment paths.
Next, the accumulator variable in XRPNotCreated was widened to 128 bits.
- std::int64_t drops_ = 0;
+ boost::multiprecision::int128_t drops_ = 0;
The invariant check can now calculate the actual change regardless of the int64 range. In addition, precautionary checks were added to the helper functions that send amounts to multiple accounts (TokenHelpers.cpp). Note that this commit did not include any test code.
Patch Deployment
On the XRPL, fixes that change transaction processing results are normally deployed through amendments. Because an amendment is activated only after support from more than 80% of validators has been maintained for two weeks, all servers begin applying the new rules at the same point in time. The Batch-related fix included in the same 3.4.1 release was also deployed through an amendment, fixBatchV1_2.
However, the overflow fix is not gated by an amendment. It takes effect as soon as a server is updated to 3.4.1, and the report also states that this is the first time in more than ten years since amendments were introduced that a transaction processing change has been deployed in this way.
The reasoning is set out in the report. Because xrpld is open source, following the amendment process would leave the fix publicly visible for several weeks, during which anyone could review the diff and find the bug. The report explains that such a window was difficult to accept, given that the bug was inexpensive to exploit and required no special privileges. Accordingly, only binaries were released on September 25, and the source code and details were published on October 9.
Admittedly, there were also views that the risk was significant. If updated and non-updated servers process an attack transaction differently, the ledger state can diverge. The report judged that, in such a case, a network halt would be preferable to the attack transaction being processed, and in practice, more than 80% of the default UNL validators updated to 3.4.1 on the day of release. Since fixBatchV1_2 was activated on October 9, servers running versions earlier than 3.4.1 have become amendment blocked and cannot stay in sync with the network.
This approach also drew controversy. Justin Bons of Cyber Capital criticized the release of binaries without source code and the pressure to upgrade, going so far as to say that promoting XRP as decentralized is fraud. The XRPL Foundation countered that the decision was made jointly by the Foundation, RippleX, core developers, and validators, and that each operator decides which software to run. Cointelegraph
Conclusion
The researcher subsequently received a bounty of $250,000. In my view, $250,000 is somewhat low for a vulnerability that made unlimited money duplication possible with ordinary privileges and a readily affordable amount of funds. However, since this is the maximum amount set by the vendor, I believe it is also important to use this case as an opportunity to raise the bounty cap and thereby prevent exploitation.
References