Patch Thursday — 웹3 해킹 피해자 구출 작전

ChainLight 팀이 Web3 보안을 강화하기 위해 위믹스파이(WEMIX.Fi) 해킹 피해자의 자산을 구출한 사례! 화이트 햇 해킹과 보안 감사의 중요성을 확인하세요.
ChainLight's avatar
Jul 27, 2023
Patch Thursday — 웹3 해킹 피해자 구출 작전

개요

2023년 7월 24일, 한 암호화폐 텔레그램 그룹에 해킹 피해 도움을 요청하는 글이 올라왔습니다. 피해자는 개인 키(private key) 유출로 인해 위믹스파이(WEMIX.Fi) 지갑의 자산을 탈취당한 상황이었습니다. 위믹스파이는 교환, 대출, 스테이킹(staking)을 지원하는 디파이(DeFi) 플랫폼으로, 한때 약 700억 원에 달하는 TVL(Total Value Locked)을 보유했던 서비스입니다.

공격자는 피해자의 개인 키를 이용해 피해자의 지갑에 있던 약 7,033 WEMIX와 지갑에 남아있던 cWEMIX를 이용해 담보되어 있던 4,567 WEMIX를 탈취했습니다.

  • 참고: cWEMIX는 컴파운드(Compound) V2를 포크(fork)한 위믹스파이의 대출 프로토콜에서 담보로 사용되는 ibToken(Interest-bearing Token)을 의미합니다.

하지만 ChainLight는 해커가 아직 탈취하지 않은 피해자의 자산이 남아있는 것을 인지했고, 곧바로 피해자 자산 구출 작전에 착수했습니다.

구출 작전에 대한 자세한 내용을 소개하기 전에, 화이트 햇 해커(White Hat Hacker)와 윤리적 해킹(ethical hacking)의 중요성에 대해 간략히 설명드리고자 합니다.

화이트 햇 해커는 어떤 사람들일까?

화이트 햇 해커란, 윤리 의식을 지키며 취약점을 찾고 적절한 조치 사항을 안내하는 등의 윤리적인 해킹 활동을 하는 보안 전문가를 뜻합니다. 화이트 햇 해커는 특정 개인 혹은 조직에서 부여한 권한 하의 해킹만을 수행하며, 발견한 취약점을 공유하고 시스템의 안전성을 보완하는 데 기여합니다.

블랙 햇 해커가 취약점을 악용해 사익을 취하거나 타인의 권리를 침해하는 행위를 자행하는 데에 반해 화이트 햇 해커는 보안 위협을 방어하는 데에 초점을 두고 활동합니다.

화이트 햇 해커에 대해 더 궁금하신 점이 있다면, 이뮨파이(Immunefi) CEO의 인터뷰를 참고하시기 바랍니다.

ChainLight는 웹2 뿐만 아니라 웹3의 보안 전문가인 화이트 햇 해커들로 구성되어 있습니다. 웹3 프로젝트에서 다양한 취약점을 찾아내 고객사 혹은 프로젝트에 보고하거나 버그 바운티(Bug Bounty) 프로그램을 통해 신고하여 생태계 보안에 기여하고 있습니다.

또한 ChainLight는 DEF CON 30, Paradigm CTF 22’, Curta CTF, RareSkills CTF, Ingonyama CTF 등의 각종 CTF에서 우승한 전적이 있는 팀원들이 포진해 있으며, 블러(BLUR), 테라스왑(TerraSwap),클레이튼(Klaytn) 등 굴지의 프로젝트들을 보안감사(Security Audit)한 바 있습니다.

  • ChainLight의 성과에 대해 더 자세히 알고 싶으시다면 본 링크를 참고해주시기 바랍니다.

웹3에서 화이트 햇 해커가 중요한 이유

웹3 환경에서는 다양한 보안 사고와 사건들이 끊이지 않습니다. 웹3 환경은 보안 전문가에게도 친숙하지 않고, 취약점 발견과 대응을 하기 위해서는 방대한 배경 지식과 전문성을 필요로 하기 때문에, 보안 사고의 불길이 쉽사리 잡히지 않고 있습니다.

웹3에서의 화이트 햇 해커 역할은 웹2에서의 역할만큼이나 굉장히 중요합니다. 화이트 햇 해커는 블랙 햇 해커가 웹3에서 발생할 수 있는 취약점을 사전에 탐지 및 수정하고, 해킹 사고 발생 시에는 사용자들의 자금을 보호할 수 있는 역량이 있기 때문입니다. 웹3에서 보안 사고의 빈도가 상당히 높기 때문에, 화이트 햇 해커들의 기여가 절실하게 필요한 상황입니다.

이번 해킹 자금 구조 사건은 ChainLight 팀의 화이트 해킹을 통해 피해자의 자금이 일부 구제된 사례입니다. 본 글을 통해 화이트 햇 해커의 중요성이 한번 더 부각될 수 있는 계기가 되기를 바랍니다.

  • ChainLight에 대해 더 자세히 알고 싶으시다면 본 링크를 참고해주시기 바랍니다.

배경

위믹스 3.0 생태계

위믹스 3.0은 EVM(Ethereum Virtual Machine)과 호환 가능한 블록체인으로, SPoA(Stake-based Proof of Authority)라는 합의 알고리즘(consensus algorithm)에 따라 운영됩니다. 블록의 검증을 위해 위믹스 3.0은 거버넌스(governance)를 통해 원더(WONDER) 또는 NCP(Node Council Partners)라고 불리는 40명의 검증자를 선정하고 검증 노드를 운영합니다. 또한 모든 원더는 검증 노드 운영을 위해 최소 150만 개의 WEMIX 토큰을 자신이 맡은 검증 노드에 스테이킹해야 합니다. 블록 보상은 각 검증자가 스테이킹한 총 스테이킹 금액에 비례하여 분배됩니다. 검증된 소수의 멤버들만 블록 생성에 참여하고 블록의 수수료를 설정하기 때문에 MEV(Maximal Extractable Value)가 존재하지 않으며, 프라이빗 트랜잭션(private transaction)을 전송하는 것은 불가능에 가깝습니다.

위믹스(사명)는 대출, 차입, 스테이킹, 스왑(swap) 등 금융 서비스를 지원하는 위믹스 3.0 기반의 공식 DeFi 플랫폼 위믹스파이를 서비스하고 있습니다. 독자분들의 이해를 돕기 위해 피해자가 이용했던 위믹스파이의 차입과 유동성 스테이킹에 대해 간략히 설명드리고자 합니다.

위믹스파이 대출 서비스는 과담보를 기반으로 하는데, 이는 담보가 대출금보다 높은 가치를 가져야 한다는 것을 의미합니다. 채무자는 청산을 방지하기 위해 담보 대비 대출 가치의 비율인 담보인정비율(LTV)이 일정 수준을 넘지 않도록 관리해야 합니다. 채무자는 일정 금액의 대출금을 상환하면, LTV가 정상적으로 유지되는 한 일부 담보를 회수할 수 있습니다.

사용자는 위믹스파이의 유동성 스테이킹(Liquid Staking) 서비스에 위믹스를 예치할 수도 있습니다. 사용자는 유동성 스테이킹을 통해 stWEMIX를 받을 수 있는데, 스테이킹을 통해 사용자가 간접적으로 합의에 참여하기 때문에 블록 보상의 일부를 stWEMIX로 분배받게 됩니다. 사용자가 스테이킹한 WEMIX의 출금 요청 시, 사용자의 출금 정보를 이용한 NFT(대체 불가능 토큰 ﹒Non-Fungible Token)가 발행됩니다. 사용자는 이 NFT를 통해 7~14일의 기간 뒤에 스테이킹한 자산을 출금할 수 있습니다.

작전 세부 정보

작전 계획

ChainLight는 피해자의 지갑에 남아 있는 자산 두 가지를 식별했습니다.

  1. 담보로 맡긴 WEMIX(cWEMIX): 피해자는 약 8,000개의 WEMIX를 담보로 위믹스3.0의 스테이블코인(stable coin)인 WEMIX$를 대출받았습니다. 과담보 기반 대출이었기 때문에 상환 후 상환금과 담보금의 차액인 3,062 WEMIX를 회수할 수 있었습니다.

  2. 위믹스파이에 스테이킹된 WEMIX: 탈취되지 않은 6,005개의 WEMIX가 스테이킹된 상태로 남아있었습니다.

구출 작전 시작 전, 저희 팀은 사전에 위협이 될 수 있는 요소를 식별 및 제거하고자 하기의 위험 가능성을 고려했습니다.

  1. 대출 상환 직후, 공격자의 cWEMIX 인출 시도 위험성

  2. 언스테이킹(unstaking) 요청 직후 공격자가 NFT를 탈취할 가능성

저희는 상기의 상황을 방지하기 위해 대출 상환과 cWEMIX 이체를 한 번에 진행해야 했습니다. 만일 실패할 경우, 피해자의 자산이 탈취당할 수 있는 위험이 도사리고 있었기 때문에 한 치의 오차도 없이 신속하게 구출 작전을 수행해야 했습니다.

작전 실행

저희 팀의 첫 번째 목표는, 담보로 예치한 cWEMIX를 회수하는 것이었습니다. 대출금 상환 직후 cWEMIX를 탈취하려는 시도를 방어하기 위해, 저희는 Whitehat Contract에 cWEMIX의 사용 권한을 사전에 승인(approve)해두었습니다. 이를 통해 한 번의 트랜잭션으로 대출 상환과 cWEMIX 전송 작업을 수행할 수 있었습니다. 저희는 위스왑(Weswap)의 플래시 론(flash loan)을 활용하여 빌린 WEMIX$와 소량의 WEMIX를 상환했고, 3,062개의 WEMIX를 회수하는 데 성공했습니다.

두 번째는, 공격자가 스테이킹된 자산을 발견하기 전에 언스테이킹 하는 것이었습니다. 저희는 위험 요소를 인지하자마자 언스테이킹을 요청했고, NFT를 발행했습니다. 이후 즉시 해당 NFT를 ChainLight 계정으로 전송했습니다. 피해자가 7일 후에 NFT를 통해 언스테이킹 가능한, 남아있던 6,005 WEMIX를 보호하는 데 성공했습니다.

작전 결과

작전 결과, 저희는 총 9,000개 가량의 WEMIX를 구출할 수 있었습니다. 구출한 모든 WEMIX와 NFT는 피해자분께 안전히 반환되었습니다.

웹3가 가지는 탈중앙화의 특징은 강력한 이점을 제공하기도 하지만 그에 따른 위험도 수반합니다. 항상 보안에 우선 순위를 두고 개인 키와 중요 정보를 잠재적인 노출 지점과 멀리하는 것이 중요함을 인지하며 안전하게 웹3 서비스를 이용하시기 바랍니다.

후속 조치 사항

저희는 공격자의 공격 이후, 후속 조치로 트랜잭션을 분석하여 피해자 자산의 이동 경로를 추적했습니다. 피해자의 자산은 공격자의 계정인, 0x3c630F1bDA1224649D372FE2d072Fb66D7d9681d로 이동한 후 0x005694209839fBc556E902351EbB17fC29591e15로 전송되었습니다.

해당 주소는 클레이튼 블록체인 상에서도 확인할 수 있으며, 과거 오케이엑스(OKX Web3) 거래소에서 공격자의 계정으로 클레이튼 자산을 전송한 이력이 존재합니다.

또한, 탈취된 자산은 중앙화 거래소인 후오비(Huobi)로 전송되었습니다. 해당 거래소 관계자분께서는 본 게시물을 보시게 된다면, ChainLight 팀에 연락을 취해주시기 바랍니다.


참고 자료


부록 A. Whitehat Contract& 명령어

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

import "forge-std/Test.sol";

interface CEther {
    function redeemUnderlyingMax() external;
    function balanceOf(address) external returns (uint256);
    function repayBorrowBehalf(address borrower) external payable;
    function borrowBalanceCurrent(address) external returns (uint256);
    function approve(address spender, uint256 amount) external;
    function transferFrom(address, address, uint256) external;
}

interface CErc20 {
    function redeemUnderlyingMax() external;
    function balanceOf(address) external returns (uint256);
    function repayBorrowBehalf(address borrower, uint256 repayAmount) external returns (uint256);
    function borrowBalanceCurrent(address) external returns (uint256);
    function approve(address spender, uint256 amount) external;
    function transferFrom(address, address, uint256) external;
}

interface ERC20 {
    function balanceOf(address) external returns (uint256);
    function transfer(address, uint256) external;
    function approve(address, uint256) external;
    function withdraw(uint256) external;
    function deposit() external payable;
}

interface ERC721 {
    function transferFrom(address from, address to, uint256 tokenId) external;
}

interface weSwap {
    function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
    function getReserves() external view returns (uint256, uint256);
}

interface stakingPool {
    function withdraw(uint256, uint256, address) external;
    function claim(uint256, address) external;
}

// block number : 23956736

contract PoCTest is Test {
    CEther cWEMIX;
    CErc20 cWEMIX$;

    ERC20 WEMIX$;
    ERC20 WWEMIX;
    ERC721 NCP_NFT;

    address VICTIM;
    address CHAINLIGHT_ADDR;

    stakingPool NCP_STAKING;
    WhiteHatCollateral WHITEHAT;

    function setUp() public {
        VICTIM = address() // private for privacy reasons
        cWEMIX = CEther(0x34b9B18fDBE2aBC6DfB41A7f6d39B5E511ce3e23);
        cWEMIX$ = CErc20(0x04dC57f9675e9a620f2566eAE20d44ACDa890802);
        WEMIX$ = ERC20(0x8E81fCc2d4A3bAa0eE9044E0D7E36F59C9BbA9c1);
        WWEMIX = ERC20(0x7D72b22a74A216Af4a002a1095C8C707d6eC1C5f);
        CHAINLIGHT_ADDR = address(0xB49bf876BE26435b6fae1Ef42C3c82c5867Fa149);
        NCP_NFT = ERC721(0x1C1eD327bC7Ce9a7eEF9eCB120576055e43b30d8);
        NCP_STAKING = stakingPool(0x6Af09e1A3c886dd8560bf4Cabd65dB16Ea2724D8);

        vm.prank(CHAINLIGHT_ADDR);
        WHITEHAT = new WhiteHatCollateral();
    }

    function testWhiteHatStaking() public {
        vm.startPrank(VICTIM);
        (bool s, bytes memory data) = address(NCP_STAKING).call(
            hex"e4e09818000000000000000000000000000000000000000000000000000000000000002900000000000000000000000000000000000000000000000000000000000000290000000000000000000000000000000000000000000001454e475a06fec90000000000000000000000000000c416c31e4ac1914c71e2f40030e2a4873fbbd66100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000"
        );

        NCP_NFT.transferFrom(VICTIM, CHAINLIGHT_ADDR, 997);
        vm.stopPrank();

        vm.startPrank(CHAINLIGHT_ADDR);
        vm.roll(block.number + 7 * 3600 * 24 + 1);
        vm.warp(block.timestamp + 7 * 3600 * 24 + 1);
        NCP_STAKING.withdraw(41, 997, CHAINLIGHT_ADDR);

        console.log("Rescued WEMIX: ", CHAINLIGHT_ADDR.balance);
    }

    function testWhiteHatCollateral() public {
        console.log("Number of WEMIX required: ", cWEMIX.borrowBalanceCurrent(VICTIM));
        console.log("Number of WEMIX$ required: ", cWEMIX$.borrowBalanceCurrent(VICTIM));

        vm.startPrank(VICTIM);
        cWEMIX.approve(address(WHITEHAT), type(uint256).max);
        vm.stopPrank();

        vm.prank(CHAINLIGHT_ADDR);
        WHITEHAT.whitehatXMXMXMXMXMXM123123213123123213123();

        console.log("Rescued WEMIX: ", CHAINLIGHT_ADDR.balance);
    }
}

contract WhiteHatCollateral {
    CEther cWEMIX;
    CErc20 cWEMIX$;

    ERC20 WEMIX$;
    ERC20 WWEMIX;

    address VICTIM;
    address WHITEHAT_ADDR;

    weSwap WEMIX_WEMIX$_PAIR;

    constructor() {
        WHITEHAT_ADDR = msg.sender;

        VICTIM = address(); // private for privacy reasons
        cWEMIX = CEther(0x34b9B18fDBE2aBC6DfB41A7f6d39B5E511ce3e23);
        cWEMIX$ = CErc20(0x04dC57f9675e9a620f2566eAE20d44ACDa890802);
        WEMIX$ = ERC20(0x8E81fCc2d4A3bAa0eE9044E0D7E36F59C9BbA9c1);
        WWEMIX = ERC20(0x7D72b22a74A216Af4a002a1095C8C707d6eC1C5f);

        WEMIX_WEMIX$_PAIR = weSwap(0x00caEc2e118AbC4c510440A8D1ac8565Fec0180C);
    }

    uint256 repay;

    function whitehatXMXMXMXMXMXM123123213123123213123() external {
        require(WHITEHAT_ADDR == msg.sender);

        uint256 outWemix$ = cWEMIX$.borrowBalanceCurrent(VICTIM);
        (uint256 reserveA, uint256 reserveB) = WEMIX_WEMIX$_PAIR.getReserves();
        uint256 outWemix = cWEMIX.borrowBalanceCurrent(VICTIM);

        uint256 numerator = (reserveA) * outWemix$ * 10000;
        uint256 denominator = (reserveB - outWemix$) * 9975;
        repay = (numerator / denominator) + 1 + (outWemix * 10030 / 10000);

        WEMIX_WEMIX$_PAIR.swap(outWemix, outWemix$, address(this), hex"11");
    }

    function weswapV2Call(address from, uint256 amount0Out, uint256 amount1Out, bytes memory data) external {
        WWEMIX.withdraw(amount0Out);
        cWEMIX.repayBorrowBehalf{value: cWEMIX.borrowBalanceCurrent(VICTIM)}(VICTIM);

        WEMIX$.approve(address(cWEMIX$), type(uint256).max);
        cWEMIX$.repayBorrowBehalf(VICTIM, WEMIX$.balanceOf(address(this)));

        cWEMIX.transferFrom(VICTIM, address(this), cWEMIX.balanceOf(VICTIM));

        cWEMIX.redeemUnderlyingMax();

        WWEMIX.deposit{value: repay}();
        WWEMIX.transfer(address(msg.sender), repay);

        WHITEHAT_ADDR.call{value: address(this).balance}("");
    }

    receive() external payable {}
}

1_whitehat.sol hosted with ❤ by GitHub
forge test --rpc-url https://api.wemix.com/ --fork-block-number 23958960 -vv
[PASS] testWhiteHatCollateral() (gas: 701788)
Logs:
  Number of WEMIX required:  13798304184455
  Number of WEMIX$ required:  3162021424247517622460
  Rescued WEMIX:  3068158418797418525930

[PASS] testWhiteHatStaking() (gas: 2644965)
Logs:
  57435823591293683146987 57405956983660559967289
  57420377804311485268082 57420377804311485268082
  Rescued WEMIX:  6005754750099999223501

2_execution.md hosted with ❤ by GitHub
Approve cWEMIX to the white hat contract for utilization

cast send --rpc-url https://api.wemix.com/ --legacy --private-key $CL_PRIVATE_KEY --value 0.5ether $VICTIM_ADDRESS
cast send --rpc-url https://api.wemix.com/ --legacy --private-key $VICTIM_PRIVATE_KEY 'approve(address,uint256)' $CWEMIX $CHAINLIGHT_WHITEHAT_CONTRACT_ADDRESS 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

Deploy white hat contract

forge create ./test/1.sol:WhiteHat --rpc-url https://api.wemix.com/ --private-key $CL_PRIVATE_KEY --legacy

Transfer NFT to rescue staked WEMIX

cast send --rpc-url https://api.wemix.com/ --legacy --private-key $VICTIM_PRIVATE_KEY 'transferFrom(address,address,uint256)' $VICTIM_ADDRESS $CHAINLIGHT_ADDRESS $STAKING_NFT_ID

3_realworld.md hosted with ❤ by GitHub

✨ We are ChainLight!

ChainLight 팀은 풍부한 실전 경험과 깊은 기술 이해를 바탕으로 새롭고 효과적인 블록체인 보안 기술을 연구합니다. 연구 결과를 바탕으로 Web3 생태계의 각종 보안 위험 요소와 취약점을 사전 파악하여 제거하는 혁신적인 보안 감사 서비스를 제공합니다. 보안 감사 이후에도 온체인 데이터 모니터링 및 취약점 탐지 자동화 서비스를 이용한 지속적인 디지털 자산 위험 관리 솔루션을 제공합니다.

ChainLight 팀은 사용자들이 탈중앙화 서비스를 안전하게 활용할 수 있도록 Web3 생태계 위협으로부터의 보호에 힘쓰고 있습니다.

  • ChainLight의 더 다양한 정보를 보고 싶으시다면? 👉 Twitter 계정도 방문해 주세요.


🌐 Website: chainlight.io | 📩 TG: @chainlight | 📧 chainlight@theori.io


Originally published at https://blog.theori.io on July 28, 2023.

Share article

Theori © 2025 All rights reserved.