XRP Ledger Payment Engine Overflow 취약점 분석
개요
이번 글에서는 2026년 10월 9일 XRPL 블로그에 공개된 xrpld 3.4.1 취약점 보고서 중 Payment Engine XRP Overflow를 분석해 보겠습니다. 보고서와 함께 소스도 공개돼서, 3.4.0과 3.4.1 코드를 비교해 가며 원인과 패치를 정리했습니다.
간단히 말하면 결제 엔진이 여러 오퍼의 XRP 금액을 더할 때 오버플로 검사를 하지 않아서 생긴 버그입니다. 합계가 int64 범위를 넘어 작은 값으로 돌아가면 구매자는 그 작은 값만 내고, 판매자들은 원래 금액을 모두 받습니다. 이 취약점은 그 차이만큼 새로운 XRP가 생성되게 되는 돈복사 버그였습니다.
| 항목 |
내용 |
| 영향 버전 |
xrpld 3.4.0 이하 |
| 수정 버전 |
xrpld 3.4.1 (2026-09-25) |
| 유형 |
Integer Overflow (CWE-190) |
| 심각도 |
Critical |
| CVE |
없음 |
| 제보자 |
Cayden Liao, Veria AI |
해당 취약점은 AI 보안 연구자 Cayden Liao에 의해 발견되었으며 실제 네트워크에서 악용된 흔적은 발견되지 않았습니다. Vulnerability Disclosure Report for xrpld 3.4.1
배경
분석에 필요한 내용만 짧게 정리하겠습니다.
XRPL에서 XRP 금액은 drops 단위의 정수로 처리합니다. 1 XRP가 1,000,000 drops이고, 코드에서는 XRPAmount 타입이 int64 값 하나로 이를 들고 있습니다. 이 타입의 덧셈 연산자는 drops_ += other.drops()로 그냥 더하기만 하며 범위 검사는 없습니다.
XRP 총발행량이 1,000억 XRP(10^17 drops)이고 int64 최댓값이 약 9.2 × 10^18이라, 계정 하나의 잔액으로는 넘칠 수가 없습니다. 다만 금액 여러 개를 더하는 경우에는 10^17짜리 금액 93개만 더해도 int64 범위를 넘습니다.
결제 쪽은 XRP로 토큰을 사는 Payment가 들어오면 결제 엔진이 오더북에 올라온 판매 오퍼를 좋은 가격 순으로 소비합니다. 오더북 처리는 BookStep이라는 클래스가 맡고 있고 이번 버그도 여기서 발생합니다.
취약점 분석
BookStep::revImp()에서 오퍼를 하나씩 처리하는 부분을 보면 다음과 같습니다.
// 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;
}
오퍼마다 구매자가 내야 할 금액(stpAmt.in)을 savedIns에 모아 두고, sum(savedIns)로 지금까지의 총액을 다시 계산합니다. 이 총액이 나중에 구매자 계정에서 빠져나가는 금액이 됩니다. 반면 판매자에게 돈을 주는 건 consumeOffer()에서 오퍼별로 따로 처리합니다.
sum()은 아래처럼 std::accumulate를 그대로 호출합니다.
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());
};
std::accumulate는 앞에서 본 XRPAmount의 덧셈을 그대로 쓰기 때문에, 합계가 int64 범위를 넘어도 막히지 않고 2^64 단위로 돌아가(wrap) 작은 값이 됩니다.
정리하면 구매자는 wrap된 합계만큼만 내고, 판매자들은 오퍼에 걸린 금액을 각각 전부 받습니다. 보고서에 따르면 실제로 구매자에게 청구된 금액은 수백 drops에 수수료 정도였습니다.
공격 조건
보고서에서 설명한 공격 방식은 다음과 같습니다.
- 계정 수백 개가 각각 토큰 아주 조금을 매우 많은 XRP에 파는 오퍼를 등록
- 다른 계정에서 결제 한 번으로 이 오퍼들을 모두 소비
비용은 계정과 오퍼에 묶이는 reserve 수백 XRP(나중에 돌려받을 수 있음)와 수수료 정도이고 평범한 사용자의 권한으로 가능했습니다. RippleX는 standalone 서버에서 이를 재현해서 새로 생긴 XRP를 실제로 쓸 수 있다는 것까지 확인했다고 합니다.
이런 오퍼는 가격이 워낙 나빠서 오더북 맨 아래에 놓입니다. 일반 결제는 그 전에 다른 오퍼로 채워지기 때문에 평소 거래 중에 우연히 이 취약점이 발현될 확률은 없었으며, 보고서도 이 오퍼들로 다른 사람의 결제를 방해하는 건 현실적이지 않다고 봤습니다.
Invariant 검사 우회
XRPL에는 트랜잭션을 처리한 뒤 결과를 원장에 반영하기 전에 돌리는 invariant 검사가 있습니다. 그중 XRPNotCreated가 XRP가 새로 생기지 않았는지 확인하는 검사인데 이번 버그는 걸러내지 못했습니다.
XRPNotCreated는 계정이나 에스크로 같은 항목의 XRP 변화량을 int64 변수 하나에 누적한 뒤, 그 값이 수수료만큼 줄어든 값인지 확인합니다. 그런데 이 누적도 같은 int64 덧셈이라 똑같이 wrap됩니다. 구매자 쪽 변화량과 판매자 쪽 변화량을 더하면 결국 -(수수료)가 나와서 정상으로 판단합니다.
계정 하나의 잔액이 1,000억 XRP를 넘으면 실패하는 XRPBalanceChecks도 있지만, 받는 쪽을 수백 개 계정으로 나누면 걸리지 않습니다. 합계가 작은 값으로 돌아오려면 실제 합이 2^64 drops(약 18.45조 XRP) 근처여야 하니까, 계정당 1,000억 XRP씩 받아도 최소 185개 계정이 필요합니다. 보고서에서 계정이 수백 개 필요하다고 한 이유가 이것이고, 제보자가 추산한 결제 한 번당 약 18조 XRP도 이 값과 맞습니다. The Block
보고서에 따르면 이 invariant는 결제 엔진보다 2년 늦게 추가됐는데, 처음부터 같은 int64 덧셈을 쓰고 있었다고 합니다.
패치 분석
먼저 BookStep의 sum()이 더할 때마다 오버플로를 검사하도록 바뀌었습니다.
- 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()는 더하기 전에 결과가 int64 범위를 넘는지 확인하고 넘으면 tecPATH_DRY로 예외를 던집니다. 이 예외는 결제 경로 단위에서 처리되기 때문에 공격 결제는 일반적인 path dry(또는 partial payment) 실패로 끝나고 넘치지 않는 결제는 기존과 결과가 같습니다. 여러 결제 경로의 결과를 합치는 StrandFlow.h에도 같은 검사가 들어갔습니다.
다음으로 XRPNotCreated의 누적 변수가 128비트로 바뀌었습니다.
- std::int64_t drops_ = 0;
+ boost::multiprecision::int128_t drops_ = 0;
이제 invariant 검사는 int64 범위와 상관없이 실제 변화량을 계산할 수 있습니다. 이 외에 여러 계정에 금액을 보내는 헬퍼 함수(TokenHelpers.cpp)에도 예방 차원의 검사가 추가됐습니다. 참고로 이 커밋에 테스트 코드는 포함되지 않았습니다.
패치 배포 방식
XRPL에서는 트랜잭션 처리 결과가 바뀌는 수정을 보통 amendment로 배포합니다. amendment는 검증자 80%를 넘는 찬성이 2주 동안 유지돼야 활성화되기 때문에, 모든 서버가 같은 시점에 새 규칙을 적용하게 됩니다. 같은 3.4.1에 들어간 Batch 관련 수정도 fixBatchV1_2라는 amendment로 배포됐습니다.
그런데 오버플로 수정에는 amendment 조건이 없습니다. 서버를 3.4.1로 업데이트하는 순간 바로 적용되는 구조이고, 보고서에서도 amendment가 생긴 뒤 10년 넘게 처음으로 트랜잭션 처리 변경을 이런 식으로 배포했다고 밝혔습니다.
이유는 보고서에 나와 있습니다. xrpld는 오픈소스라 amendment 절차를 밟으면 수정 코드가 몇 주 동안 공개된 상태로 남고 그동안 누구나 diff를 보고 버그를 찾아낼 수 있습니다. 공격 비용이 낮고 특별한 권한도 필요 없는 버그라서 그 기간을 감수하기 어려웠다는 설명입니다. 따라서 9월 25일에는 바이너리만 배포했고 소스와 상세 내용은 10월 9일에 공개했습니다.
물론 위험이 크다는 의견도 있었습니다. 업데이트한 서버와 하지 않은 서버가 공격 트랜잭션을 다르게 처리하면 원장 상태가 갈라질 수 있습니다. 보고서는 이 경우 공격 트랜잭션이 처리되는 것보다 네트워크가 멈추는 편이 낫다고 판단했고 실제로 배포 당일 기본 UNL 검증자의 80% 이상이 3.4.1로 업데이트했습니다. 10월 9일 fixBatchV1_2가 활성화된 뒤로는 3.4.1 미만 서버가 amendment blocked 상태가 되어 네트워크를 따라갈 수 없습니다.
이 방식을 두고 논란도 있었습니다. Cyber Capital의 Justin Bons는 소스 없이 바이너리만 배포하고 업그레이드를 압박한 점을 비판하면서, XRP를 탈중앙화됐다고 홍보하는 건 사기라고까지 했습니다. XRPL Foundation은 재단, RippleX, 핵심 개발자, 검증자가 함께 내린 결정이고 어떤 소프트웨어를 쓸지는 각 운영자가 정한다고 반박했습니다. Cointelegraph
마치며
이후 해당 연구자는 25만 달러의 바운티를 지급받게 되었습니다. 개인적인 생각으로는 평범한 권한과 충분히 감당 가능한 금액으로 무한 돈 복사 버그가 가능한 취약점인데 25만 달러는 조금 적다는 생각이 있습니다. 다만 이는 벤더사에서 지정해둔 최고가이기 때문에 이 일을 계기로 바운티 금액의 상한을 증가시켜 악용 사례를 막는 것도 중요하다 생각합니다.
참고 자료