지난 한 주(2026/07/20 - 2026/07/26) 동안 총 약 $39.5M의 손실을 수반한 주목할 만한 보안 사고 8건이 소개됩니다.
| 날짜 | 사고 | 유형 | 예상 손실 |
|---|---|---|---|
| 2026/07/19* | Allbridge | 결함 있는 입력 유효성 검사 | ~$1.65M |
| 2026/07/20 | Zilliqa | 결함 있는 논스 생성 | ~$400K |
| 2026/07/20 | Wanchain | 결함 있는 메시지 인코딩 | ~$500K |
| 2026/07/22 | 42DAO | 개인 키 탈취 | ~$900K |
| 2026/07/22 | AFX Trade | 개인 키 탈취 | ~$24.15M |
| 2026/07/22 | B² Network | 개인 키 탈취 | ~$3.8M |
| 2026/07/23 | Verus | 개인 키 탈취 | ~$7.6M |
| 2026/07/24 | Lien Finance | 결함 있는 유효성 검사 로직 | ~$542K |
*Allbridge 사고는 7월 19일(17:51 UTC)에 발생하였으며 지난주 보고서에는 포함되지 않았습니다. 완전한 기록을 위해 이번 보고서에 포함합니다.
- Zilliqa는 오프체인 지갑 구현에서 오랫동안 감지되지 않은 취약점을 노출시켰기 때문에 선정되었습니다. 이 취약점은 2019년부터 레거시 네이티브 ZIL 계정 사용자들을 개인 키 탈취 위험에 노출시켜 왔습니다.
- Allbridge는 Solana의 위치 기반 계정 모델에 의해 가능해진 계정 별칭(account-aliasing) 취약점을 보여주기 때문에 선정되었습니다. 소스 코드를 이용할 수 없어 배포된 프로그램 바이너리로부터 취약점을 재구성해야 했습니다.
- Wanchain은 암호학을 손상시키지 않고도 브리지를 탈취할 수 있음을 보여주기 때문에 선정되었습니다. 서명은 유효하고 올바르게 검증되었지만, 비단사적(non-injective)이고 구분자가 없는 메시지 인코딩으로 인해 3,097.56 토큰에 대한 승인이 Cardano에서 203,001,692.164714 토큰으로 재해석되었습니다.
- Lien Finance는 매칭된 항목의 신원과 중복 여부를 확인하지 않고 잔액이 수량과 일치하는지만 검증하는 방식이 어떻게 우회될 수 있는지를 잘 보여주기 때문에 선정되었습니다.
Web3 최고의 보안 감사 기관
출시 전 설계, 코드, 비즈니스 로직을 검증하세요
주간 하이라이트: Allbridge Core
이 사고는 계정 별칭 패턴이 동일한 가변 계정을 두 개의 명령 역할로 받아들이는 모든 Solana 프로그램에 적용될 수 있기 때문에 강조됩니다. 기본 메커니즘인 두 번째 쓰기가 첫 번째 쓰기를 조용히 덮어쓰는 동일한 상태에 대한 두 개의 가변 뷰는 고전적인 ERC-20 자기 전송 버그(from == to인 transferFrom)와 동일한 구조입니다. 프로젝트의 사후 분석 보고서 [1]는 개요를 제공하지만 코드 수준의 메커니즘을 자세히 설명하지 않습니다. 아래 분석은 그 공백을 메우기 위해 배포된 프로그램 바이너리로부터 재구성되었습니다.
2026년 7월 19일(17:51 UTC), Solana의 크로스체인 브리지 프로토콜인 Allbridge Core가 약 $1.65M(~$1.12M의 USDC와 ~$539K의 USDT)을 탈취당했습니다. 근본 원인은 스왑 명령이 두 가지가 서로 다른지를 강제하지 않은 채 동일한 Pool 계정을 송신 및 수신 역할 모두에서 받아들였다는 것입니다. 동일한 계정이 두 번 전달되었을 때, 한 역할의 내부 회계 업데이트가 다른 역할에 의해 조용히 덮어씌워졌고, 그 사이 실제 토큰 전송은 이미 완료된 상태였습니다. 다섯 번의 셀프 스왑으로 Pool의 기록된 상태가 충분히 왜곡되어 공격자는 소량의 USDT 입력을 ~$2.24M USDC로 전환할 수 있었습니다.
배경
Allbridge Core는 vUSD라는 내부 회계 단위를 통해 스왑을 처리하는 크로스체인 브리지입니다. 스왑은 두 개의 회계 구간으로 구성됩니다:
source token -- swap_to_v_usd(send_pool) --> vUSD
vUSD -- swap_from_v_usd(receive_pool) --> destination token
vUSD는 SPL 토큰이 아닙니다. 각 Pool의 온체인 데이터 내 필드로만 존재합니다. 각 Pool은 token_balance, v_usd_balance, reserves를 추적합니다. 송신 구간은 소스 토큰을 사용자로부터 송신 브리지 볼트로 전송하고, 송신 Pool의 토큰 측 회계를 증가시키며, vUSD 출력을 계산합니다. 수신 구간은 해당 vUSD를 수신 Pool에 추가하고, 토큰 측 회계를 감소시키며, 수신 브리지 볼트에서 사용자에게 목적지 토큰을 전송합니다.
Solana 프로그램은 호출자로부터 위치 배열 형태로 계정을 받습니다. 프로그램의 명령 핸들러는 어떤 배열 위치가 어떤 역할(send_pool, receive_pool, send_mint 등)에 해당하는지를 지정하지만, 런타임은 호출자가 두 위치에 동일한 계정 주소를 전달하는 것을 막지 않습니다. 프로그램이 두 위치에서 계정을 역직렬화할 때 각 역직렬화는 별도의 인메모리 객체를 생성합니다. 한 객체에 대한 변경은 다른 객체에 영향을 미치지 않습니다. 동일한 계정이 두 번 전달되면, 두 객체는 동일한 온체인 데이터에서 시작하지만 어느 하나가 변경되는 순간 서로 달라집니다.
핸들러가 종료되면, 프로그램의 종료 로직(예: Anchor의 AccountsExit)은 각 계정 객체를 고정된 순서로 계정의 데이터에 직렬화합니다. 두 객체가 동일한 계정에 매핑되면, 두 번째 직렬화가 첫 번째 직렬화를 완전히 덮어씁니다.
취약점 분석
영향을 받은 Allbridge Core 배포의 정확한 소스 코드는 이용할 수 없었습니다. 분석은 ProgramData에 저장된 1,770,736바이트 ELF(SHA-256: 40f776...346bb6)로부터 재구성되었습니다. 마지막 배포 슬롯(204,727,029)은 공격 슬롯(433,941,722)보다 앞서 있습니다.
버그가 있는 프로그램은 BrdgN2...ceWB입니다. 공격 트랜잭션의 Swap 명령(명령 3~7)에서 송신 및 수신 계정 쌍 네 개가 동일했습니다:
| 역할 | 계정 |
|---|---|
send_mint / receive_mint |
Es9vMF...wNYB |
send_pool / receive_pool |
DW4a2E...wCX |
send_bridge_token / receive_bridge_token |
2xY9TD...vohV |
send_user_token / receive_user_token |
817UdW...CVct |
파서에는 각 역할을 예상 민트, 볼트, 소유자, PDA, 권한, 또는 Token 프로그램에 바인딩하는 16개의 복구된 32바이트 비교가 포함되어 있습니다. 그러나 send_pool.key() != receive_pool.key()를 강제하는 비교는 없습니다. 두 Pool 역할은 핸들러가 실행되기 전에 독립적으로 역직렬화됩니다.
배포된 ELF는 두 개의 Pool 구성, 송신 및 수신 계산, 그리고 고정된 순서의 두 개의 Pool 종료를 보여줍니다:
L54249-L54252 function_9881(accounts[5]) -> send_pool local
L54294-L54297 function_9881(accounts[6]) -> receive_pool local
L74177-L74195 function_12623(send_pool) -> swap_to_v_usd
L74368-L74389 function_12940(receive_pool) -> swap_from_v_usd
L55932-L55936 function_8300(send_pool) -> 전체 Pool 직렬화
L55940-L55944 function_8300(receive_pool) -> 전체 Pool 직렬화
각 function_9881 호출은 별도의 176바이트 로컬 Pool 객체를 할당하고 디코딩된 필드를 그 안에 복사합니다. 계정 키가 동일할 때, 두 객체는 동일한 데이터에서 시작하지만 송신 객체를 변경해도 수신 객체는 변경되지 않습니다.
재구성된 의사 Rust 코드(소스 복구가 아닌 배포 동작) [2] [3]:
let mut send_pool = Account::<Pool>::try_from(&accounts[5])?;
let mut receive_pool = Account::<Pool>::try_from(&accounts[6])?;
require_keys_eq!(send_pool.mint, send_mint.key());
require_keys_eq!(receive_pool.mint, receive_mint.key());
// 볼트, 소유자, PDA, 권한, Token 프로그램 바인딩이 확인됩니다.
// 누락: require_keys_neq!(send_pool.key(), receive_pool.key());
token::transfer(send_user_token, send_bridge_token, amount)?;
let v_usd = swap_to_v_usd(&mut send_pool, amount)?;
let receive_amount =
swap_from_v_usd(&mut receive_pool, v_usd, receive_amount_min)?;
token::transfer(receive_bridge_token, receive_user_token, receive_amount)?;
// 핸들러 반환 후 AccountsExit:
send_pool.exit(program_id)?; // 첫 번째 쓰기
receive_pool.exit(program_id)?; // 두 번째 쓰기, 첫 번째를 덮어씀
function_8300은 위치 0에서 기록을 시작하여 정의된 131개의 Pool 바이트를 모두 직렬화합니다. 첫 번째 종료는 send_pool을 기록하고, 두 번째 종료는 동일한 바이트 위에 receive_pool을 덮어씁니다. 따라서 최종 계정 상태는 두 로컬 업데이트의 병합이 아닌 오래된 수신 측 값이 됩니다.
SPL 토큰 전송은 별도의 CPI입니다. Pool 종료 이전에 SPL Token 프로그램이 소유한 볼트 계정을 업데이트하므로, 두 번째 Pool 직렬화는 입력 전송을 되돌릴 수 없습니다. 따라서 각 별칭 스왑은 전송된 토큰을 볼트에 남겨두면서 대응하는 송신 측 Pool 업데이트를 폐기합니다. 수신 측 업데이트는 유지되어 기록된 Pool 상태를 더 높은 v_usd_balance와 더 낮은 토큰 측 회계 방향으로 이동시킵니다.
핵심 결함은 프로그램이 send_pool과 receive_pool이 서로 다른 계정인지 확인하지 않는다는 것입니다. 두 역할이 동일한 Pool에 합법적으로 속하기 때문에, 다른 모든 유효성 검사(민트 바인딩, 볼트 소유권, PDA 유도)는 통과됩니다.
공격 분석
다음 분석은 트랜잭션 3LNLaG...Y39Q를 기반으로 합니다.
익스플로잇은 Kamino 플래시 차입, 일곱 개의 Allbridge Swap 명령, Kamino 상환이 포함된 하나의 트랜잭션으로 완료되었습니다.
- 1단계: 공격자는 Kamino에서 ~1.12M
USDC를 차입하고 Allbridge를 통해 ~949KUSDT로 스왑했습니다(명령 1-2). 이 첫 번째 스왑은 이후 셀프 스왑에 필요한USDT를 공급하고USDC및USDTPool 상태를 변화시켰습니다.

- 2단계: 공격자는 송신 및 수신 모두에 동일한 민트, Pool, 볼트, 사용자 토큰 계정을 사용하여 다섯 번의 ~100K
USDT셀프 스왑을 제출했습니다(명령 3-7). 매 호출마다 수신 Pool이 마지막으로 직렬화되었기 때문에, 지속된 회계는 수신 측 감소만 기록하고 송신 측 추가는 기록하지 않았습니다. 왜곡이 누적되면서 관찰된 출력은 각 호출마다 감소했습니다:
| 셀프 스왑 | 입력 | 기록된 vUSD |
가격 (vUSD/USDT) | USDT 출력 |
|---|---|---|---|---|
| 1 | ~100K | ~162K | ~5.24 | ~47.8K |
| 2 | ~100K | ~256K | ~16.6 | ~25.4K |
| 3 | ~100K | ~471K | ~57.2 | ~12.5K |
| 4 | ~100K | ~918K | ~190 | ~5.73K |
| 5 | ~100K | ~1.82M | ~563 | ~2.36K |
다섯 번의 호출로 ~500K USDT가 볼트에 전송되고 ~93.8K USDT가 반환되었습니다. Pool의 기록된 token_balance는 각 호출마다 감소한 반면 실제 볼트 잔액은 증가하여, 다음 단계가 악용하는 격차가 벌어졌습니다.

- 3단계: 공격자는 단 ~3.99K
USDT만 공급했습니다(명령 8). 왜곡된USDTPool은 ~2.24MvUSD를 생성하였고,USDCPool은 이를 ~2.24MUSDC로 전환했습니다. 다섯 번의 송신 측 업데이트 폐기 이후 Pool의 기록된 토큰 잔액이 실제 볼트 잔액보다 훨씬 낮았기 때문에 부풀려진vUSD출력이 가능했습니다.

- 4단계: 공격자는 ~1.12M
USDCKamino 플래시 론 원금과 ~11.2USDC수수료를 상환했습니다(명령 9). 최종 공격자 잔액은 ~1.12MUSDC와 ~539KUSDT였습니다.
결론
이 사고의 근본 원인은 send_pool과 receive_pool이 서로 다른 계정을 참조하는지 확인하는 유효성 검사가 누락된 것이었습니다. 단일 가변 Pool이 두 개의 로컬 회계 객체로 역직렬화되었고, Token 프로그램이 볼트 전송을 이미 완료한 후 두 번째 전체 직렬화가 첫 번째를 덮어썼습니다. 이로 인해 Pool의 기록된 회계와 실제 볼트 잔액이 분리되었습니다. 계정 별칭, ELF 제어 흐름, 직렬화 순서, 트랜잭션 로그, 볼트 잔액 모두 동일한 메커니즘을 지지합니다 [1].
Solana 프로그램의 경우, 동일한 계정 유형을 두 개 이상의 가변 역할로 받아들이는 명령은 고유성을 강제(require_keys_neq!)하거나 종료 전에 변경 사항을 병합해야 합니다. Anchor 프레임워크의 #[account] 제약 시스템은 기본적으로 역할 간 고유성을 강제하지 않으므로, 이 검사를 명시적으로 추가해야 합니다. 일반적인 패턴인 두 번째 쓰기가 첫 번째 쓰기를 조용히 덮어쓰는 동일한 상태에 대한 두 개의 가변 뷰는 프로그램이 자체 직렬화 순서를 관리하는 모든 곳에서 나타날 수 있습니다.
참고 자료
- [1] Allbridge Core, 기술 사후 분석: Solana Pool Swap 익스플로잇
- [2] Allbridge Core JS SDK, Solana Swap 계정 인터페이스 (
bridge.json) - [3] Allbridge Core EVM 컨트랙트, Pool 회계 참조 (
Pool.sol)
이번 주 추가 사고
Zilliqa Ledger 지갑
2026년 7월 20일, Zilliqa는 레거시 네이티브 ZIL 계정에 대한 활성 익스플로잇과 일치하는 온체인 활동을 확인했으며, 알려진 손실은 약 $400K입니다. 근본 원인은 Zilliqa Ledger 앱의 EC-Schnorr 서명 경로에 있는 편향된 논스였습니다: 논스 생성 코드가 모듈러 감산 후 40바이트 버퍼에서 잘못된 32바이트를 복사하여, 최상위 64비트를 0으로 고정시켰습니다. 동일한 계정에서 여러 공개 서명이 있으면, 공격자는 격자 기반 기법을 통해 개인 키를 복구하고 계정을 탈취할 수 있습니다.
배경
Zilliqa 네이티브 트랜잭션은 secp256k1에서 EC-Schnorr 서명을 사용합니다. 각 서명에 대해 서명자는 (secp256k1 곡선 차수)을 만족하는 신선하고 전폭적이며 예측 불가능한 임시 논스 를 샘플링해야 합니다. 서명 흐름은 커밋먼트 , 챌린지 , 그리고 응답 을 생성하며, 여기서 는 개인 키입니다. 브로드캐스트 후 는 공개됩니다.
선형 관계 이 핵심입니다. 올바른 구현은 모든 가 신선하고 균등하게 분포되어 있기 때문에 안전을 유지합니다. 논스가 편향되거나 너무 작으면, 각 공개 서명은 동일한 개인 키에 대한 정보를 유출합니다.
취약점 분석
관련 논스 생성 경로는 Zilliqa Ledger 앱 커밋 "Ledger 팀 검토 기반 버그 수정"에서 도입되었습니다. 의도된 흐름은 40바이트의 난수를 생성하고, 곡선 차수 으로 모듈러 감산하고, 결과 스칼라를 로 사용하는 것이었습니다. 폭넓은 난수를 생성하고 곡선 차수로 모듈러 감산하는 것 자체는 문제가 없습니다. 결함은 논스 버퍼로의 복사 과정에서 나타났습니다:
unsigned char nonce[size+8];
cx_rng(nonce, size+8);
cx_math_modm(nonce, size+8, (unsigned WIDE char *) PIC(domain->n), size);
os_memcpy(T->K, nonce, size);
cx_math_modm 이후, 32바이트 스칼라 결과는 40바이트 버퍼 내에서 오른쪽 정렬됩니다. os_memcpy(T->K, nonce, size)는 처음 size(32) 바이트를 복사하므로, 앞의 8개 제로 패딩 바이트를 유지하고 엔트로피의 마지막 8바이트를 버립니다. 따라서 생성된 논스는 대신 를 만족합니다.
이는 모든 논스의 64비트가 0으로 고정됨을 의미합니다. Schnorr 응답 방정식 으로부터, 각 서명은 결과가 로 제한되는 공개 모듈러 선형 관계를 제공합니다. 이는 은닉 수 문제(HNP, Hidden Number Problem) 인스턴스입니다. 동일한 키에서 약 네 개 이상의 영향받은 서명이 있으면, 표준 격자 감산 알고리즘이 일반 하드웨어에서 수 초 내에 개인 키를 복구합니다 [1].
이 결함은 2019년부터 2026년 7월 사고가 확인될 때까지 Zilliqa Ledger 앱의 모든 출시 버전에 존재했습니다 [2].
이 사고에 대한 공격 분석은 제공되지 않습니다. 익스플로잇은 완전히 오프체인에서 수행되었습니다: 공격자는 격자 감산을 사용하여 공개적으로 이용 가능한 온체인 서명에서 개인 키를 복구한 후, 표준 전송 트랜잭션에 서명했습니다. 분석할 다단계 온체인 공격 시퀀스가 없습니다.
결론
이 사고는 온체인 스마트 컨트랙트 버그가 아닌 오프체인 서명 구현 결함으로 인해 발생했습니다. Zilliqa Ledger 앱은 로 제약된 논스를 가진 EC-Schnorr 서명을 생성하여, 동일한 계정에서 여러 번의 네이티브 트랜잭션 후 개인 키 복구를 위한 충분한 구조화된 정보를 유출했습니다.
주요 완화책은 단순히 Ledger 앱을 업데이트하는 것이 아니라 키를 폐기하는 것입니다. 수정된 앱은 새로운 취약한 서명을 방지하지만, 이미 온체인에 기록된 공개 서명을 지울 수는 없습니다. 충분한 수의 취약한 서명을 생성한 영향받은 키는 탈취된 것으로 간주해야 합니다 [2].
지갑 및 프로토콜 팀을 위해: 논스 생성과 인코딩을 보안에 중요한 코드로 취급하고, 해당하는 경우 잘 검토된 표준을 따르는 결정론적 논스 생성을 선호하며, 영향받은 사용자에게는 이미 동일한 개인 키를 보유한 공격자에 의한 프런트런닝을 고려한 조율된 마이그레이션 경로를 제공하세요.
참고 자료
Wanchain Cardano 브리지
2026년 7월 20일, Wanchain Cardano 브리지는 Cardano TreasuryCheck Plutus 검증기의 비단사적 메시지 인코딩으로 인해 약 515.2M NIGHT(~$500K)를 손실했습니다 [1]. 프로젝트의 사후 분석 보고서 [2]는 고수준 메커니즘을 설명하지만 바이트 수준의 인코딩 세부 사항이나 코드를 제공하지 않습니다. 아래 분석은 전체 충돌을 재구성합니다. 브리지 노드 서명은 유효하고 올바르게 검증되었지만, 가변 길이 필드의 구분자 없는 연결로 인해 공격자가 두 인접 숫자 필드 간의 바이트 경계를 이동시켜 3,097.56 NIGHT에 대한 승인을 203,001,692.164714 NIGHT 인출로 재해석할 수 있었습니다.
배경
영향받은 구성 요소는 Wanchain의 Cardano 크로스체인 트레저리 시스템입니다. 소스 체인 사용자가 토큰을 소각하거나 잠그면, 브리지 노드가 파싱된 리디머 필드로부터 구성된 승인 메시지에 서명하고, 서명된 증명이 Cardano TreasuryCheck Plutus 검증기에 제출되어 트레저리에서 자산이 해제됩니다.
취약점 분석
TreasuryCheck 검증기는 14개의 파싱된 리디머 필드의 원시 연결에 대한 브리지 노드 서명을 검증합니다:
hashRedeemer = sha3_256 $ mconcat
[ toPkhPay, toPkhStk, policy, assetName
, packInteger amount, packInteger adaAmount
, txHash, packInteger index, packInteger mode
, uniqueId, packInteger txType, packInteger ttl
, packInteger outputCount, userData
]
packInteger가 사용하는 정수 인코딩은 가변 길이이며, 연결에는 길이 접두사, 유형 구분자, 또는 도메인 분리된 타입 직렬화가 없습니다. 따라서 서로 다른 의미론적 튜플이 동일한 서명된 바이트 문자열을 생성할 수 있습니다.
대표적인 공격에서, 브리지 노드는 정상적인 인출에 서명했습니다:
amount = 3097560000 -> packInteger = b8a103c0
adaAmount = 1206800 -> packInteger = 126a10
combined = b8a103c0126a10
공격자는 다음과 같이 파싱된 Cardano 리디머를 제출했습니다:
amount = 203001692164714 -> packInteger = b8a103c0126a
adaAmount = 16 -> packInteger = 10
combined = b8a103c0126a10
바이트 시퀀스는 동일합니다. 서명 검사가 통과되었고, Cardano 컨트랙트는 리디머를 승인된 것보다 약 65,000배 큰 인출로 해석했습니다.
공격 분석
다음 분석은 대표적인 Cardano 트랜잭션 0a4861...2ea1과 BSC 소스 트랜잭션 0xe90111...d5f26b을 참조합니다.
-
1단계: 공격자는 정상적으로 보이는 소스 체인 브리지 요청을 생성했습니다. 대표적인 경우, BSC 트랜잭션은 3,110
NIGHT를 소각하였고, Wanchain API는 예상 Cardano 수신 금액을 3,097.56NIGHT로 기록했습니다 [3]. -
2단계: 브리지 노드가 원시 연결 필드로부터 구성된 승인 해시에 서명했습니다.
-
3단계: 공격자는 서명된 바이트 시퀀스를 변경하지 않으면서
amount와adaAmount사이의 Cardano 리디머 경계를 변경했습니다. -
4단계: Cardano
TreasuryCheck검증기가 리디머를 고액 인출로 파싱하고 재사용된 서명을 성공적으로 검증했습니다. 트랜잭션은 공격자에게 203,001,692.164714NIGHT를 지불했습니다. -
5단계: 동일한 패턴이 여러 Cardano 트랜잭션에 걸쳐 반복되어 총 약 515,206,545.426856
NIGHT(~$500K)가 탈취되었습니다.
결론
어떠한 암호화도 해독되지 않았습니다. 서명자들은 유효한 서명을 생성했고, TreasuryCheck 검증기는 이를 올바르게 검증했습니다. 결함은 메시지 구성에 있었습니다: hashRedeemer가 14개의 가변 길이 필드를 구분자나 길이 접두사 없이 하나의 바이트 문자열로 접었기 때문에 인코딩이 비단사적이 되었습니다. 서로 다른 필드 튜플이 동일한 원상, 동일한 해시, 동일한 유효한 서명을 생성합니다.
수정책은 서명된 인코딩을 단사적으로 만들어 정확히 하나의 필드 튜플만이 주어진 바이트 문자열을 생성할 수 있게 하는 것입니다. 고정 폭 정수 인코딩이나 각 필드에 길이 접두사를 추가하면 공격자가 이동시킨 경계가 고정됩니다. 표준 구조화된 인코더도 동일한 결과를 달성하며 오류 발생 가능성이 낮습니다. 일반 규칙: 원시 연결이 아닌 정규 직렬화에 서명하세요. 이는 가변 길이 인수와 함께 Solidity의 abi.encodePacked를 사용할 때와 같은 버그 유형으로, 서로 다른 입력 튜플이 동일한 바이트 시퀀스를 생성할 수 있습니다. 이는 가변 길이 필드로 조립된 서명된 메시지에 적용되며, 메시지를 구성하고 파싱하는 당사자들이 서로 다른 시스템에서 운영되는 브리지에서 특히 관련성이 높습니다.
참고 자료
- [1] BlockSec Phalcon의 Wanchain 익스플로잇 게시물
- [2] Wanchain Cardano-BNB Chain 브리지 사고 사후 분석
- [3] 대표적인 BSC 트랜잭션에 대한 Wanchain 상태 API 기록
Lien Finance
2026년 7월 24일, Ethereum의 탈중앙화 채권 OTC 프로토콜인 Lien Finance가 USDC 약 $542K를 탈취당했습니다. 근본 원인은 채권 교환 함수의 유효성 검사 결함이었습니다: 입력 및 출력 그룹 간에 공유 채권의 총 수가 일치하는지 확인했지만, 어떤 특정 채권이 매칭되었는지는 추적하지 않았습니다. 이를 통해 공격자는 모든 입력 채권의 소각 단계를 우회하면서 담보 없는 채권을 발행하고, 이를 프로토콜의 OTC 풀을 통해 실제 USDC로 판매할 수 있었습니다.
배경
Lien Finance는 ETH에 대한 담보 채권을 발행하는 DeFi 프로토콜입니다. BondMakerCollateralizedEth 컨트랙트는 사용자가 채권 토큰을 발행하기 위해 ETH를 담보로 잠글 수 있게 합니다. 채권의 수익은 fnMap으로 정의되며, 이는 만기 시 담보 가격을 해당 채권이 지급하는 금액에 매핑하는 구간 선형 함수입니다.
의미 있는 단위는 채권 그룹입니다. registerNewBondGroup()은 그룹 내 모든 채권의 수익이 결합된 fnMap의 모든 분기점에서 ETH 가격의 합산이 되는지 검증합니다. 이 조건을 만족하는 그룹은 정확히 하나의 담보 단위를 재구성합니다. 이것이 그룹이 서로 교환 가능한 이유입니다: 해당 조건을 만족하는 두 그룹은 동일한 가치를 가지며, exchangeEquivalentBonds() 변환 경로는 이 보증에 의존합니다.
채권과 그룹 등록은 모두 허가 없이 가능합니다: 어떤 주소든 만기와 임의의 fnMap을 제공하여 새 채권을 등록할 수 있으며, 어떤 주소든 이미 등록된 채권 ID 목록을 수익 합산 검사만을 조건으로 그룹으로 등록할 수 있습니다.
취약점 분석
버그가 있는 컨트랙트는 0xDA6F...BEf0과 0x8432...7de0입니다.
채권이 입력 그룹과 출력 그룹 모두에 나타날 때, 소각하고 즉시 재발행하는 것은 낭비적인 작업입니다. exceptionBonds 매개변수는 교환이 두 단계를 모두 건너뛸 수 있도록 해당 채권을 명명합니다. 함수는 단일 카운터 exceptionCount로 이를 강제합니다: 입력 그룹을 스캔하면서 매칭마다 한 번 증가하고, 출력 그룹을 스캔하면서 매칭마다 한 번 감소하며, 어떤 bondID가 매칭되었는지는 기록하지 않습니다.

이는 입력 그룹 [BondA, BondB]와 출력 그룹 [BondC, BondA, BondA], exceptionBonds = [BondA, BondB]가 유효성 검사를 통과함을 의미합니다. 카운터는 입력 측에서 2에 도달하고(BondA와 BondB 각각 한 번 매칭) 출력 측에서 0으로 돌아오지만, 두 번의 감소는 모두 BondA의 두 항목에서 나옵니다. 교환은 아무것도 소각하지 않고 BondC만 발행합니다: 소비된 입력 없이 출력 그룹 토큰이 생성됩니다.
공격 분석
공격은 두 개의 트랜잭션으로 나뉩니다: 1단계는 0xe8689a...284d0f에서 발생하고, 2-5단계는 0xb96d57...48e0e7에서 발생합니다.
-
1단계: 공격자는 허가 없는
registerNewBond()를 세 번 호출하여 동일한 만기를 가진 BondA, BondB, BondC를 정의했습니다. BondA와 BondB는_assertBondGroup()이 요구하는 것처럼 수익이ETH가격의 합산이 되는 $3,200 이하에서 담보를 균등하게 분할하는 레버리지 콜입니다. BondC는 $1,870 현물가에 대해 $3,200에 설정된 외가격 LBT입니다: 내재 가치는 0이지만, 옵션으로서 여전히 IMT당 약 $7.64의 시간 가치가 있습니다. -
2단계: 공격자는 두 개의 채권 그룹을 등록했습니다.
registerNewBondGroup([BondA, BondB])는 그룹 36(입력)을 반환하고,registerNewBondGroup([BondC, BondA, BondA])는 그룹 37(출력)을 반환했습니다. BondC의 0 수익은 분기점 합산에 아무것도 추가하지 않으므로, 그 존재는 동등 조건을 방해하지 않습니다. BondA가 그룹 37에 두 번 나열된 것이 교환이 유효성 검사를 통과하게 만드는 요소입니다. -
3단계: 공격자는
exchangeEquivalentBonds(inputGroup = 36, outputGroup = 37, amount = 6968565865905, exceptionBonds = [BondA, BondB])를 호출했습니다. 입력 그룹 스캔: BondA와 BondB가 각각 예외와 매칭되어 소각을 건너뛰고exceptionCount가 2에 도달합니다. 출력 그룹 스캔: BondC는 아무것과도 매칭되지 않아 발행되고, BondA의 두 항목이 각각 예외와 매칭되어 카운터를 0으로 감소시킵니다. -
4단계: 공격자는 발행된 BondC를 프로토콜의 OTC 풀(
GeneralizedDotc)을 통해USDC로 판매하여 532,144USDC를 획득했습니다. -
5단계: 공격자는 두 번째
BondMakerCollateralizedEth컨트랙트에 대해 동일한 패턴을 반복하여 총 이익이 약 542,144.63USDC에 달했습니다.

결론
공유 채권의 재발행을 건너뛰기 위한 exceptionBonds 메커니즘은 출력 그룹에 bondID를 중복시킴으로써 모든 입력 채권에 한 번에 적용될 수 있었습니다. 이는 채권 토큰이 issueNewBonds()를 통해 예치된 담보에 대해서만 생성된다는 불변성을 깨뜨렸습니다. 수정책은 어떤 특정 bondID가 매칭되었는지 추적(예: 비트맵 또는 집합 사용)하여, 각 예외가 두 그룹 모두에서 정확히 한 번씩만 소비되도록 하는 것입니다.
BlockSec 소개
BlockSec은 풀스택 블록체인 보안 및 암호화폐 컴플라이언스 공급업체입니다. 저희는 고객이 프로토콜과 플랫폼의 전체 라이프사이클에 걸쳐 코드 감사(스마트 컨트랙트, 블록체인 및 지갑 포함)를 수행하고, 공격을 실시간으로 차단하며, 사고를 분석하고, 불법 자금을 추적하며, AML/CFT 의무를 충족할 수 있도록 돕는 제품과 서비스를 구축합니다.
BlockSec은 권위 있는 학회에서 다수의 블록체인 보안 논문을 발표하고, DeFi 애플리케이션의 여러 제로데이 공격을 보고하며, 2천만 달러 이상을 구제하기 위한 다수의 해킹을 차단하고, 수십억 달러의 암호화폐를 보안했습니다.
-
공식 웹사이트: https://blocksec.com/
-
공식 트위터 계정: https://twitter.com/BlockSecTeam



