Post

BMW Web3 CTF

개요

Introduction
안녕하세요. BMW는 Break My Wall의 약자로 나의 벽을 깨부순다는 의미입니다. 나의 한계를 극복함과 동시에 우리가 WEB3 생태계의 진입장벽을 부수자는 의미를 함축하고 있습니다. BMW는 KITRI 차세대 보안리더 양성 프로그램 WHS 1기 프로젝트팀이며, WEB3의 취약점을 연구하고 워게임 사이트를 제작하는 프로젝트 주제로 활동하고 있습니다.

CTF Purpose
암호화폐, 코인과 같은 블록체인 기술을 기반으로 하는 디지털 화폐가 대중성을 갖게 되며, 자연스레 블록체인 보안성의 중요성이 강조되고 있습니다. 블록체인은 투명성과 분산성, 탈중앙화라는 특성을 기반으로 두기에 기존의 Web 2.0보다 강한 보안성이 보장되지만, 그럼에도 불구하고 블록체인의 세계에서도 해킹 및 보안사고는 끊임없이 발생하고 있습니다. 저희 Team BMW는 이러한 블록체인 보안의 중요성이 대두되는 시기에 화이트해커들의 Web3.0 보안 역량을 강화하고 발전시키기 위해 해당 프로젝트를 진행하게 되었습니다.

24년 1월 1일인가 2일부터인가 BMW라는 Web3 CTF가 있어서 해봤다. 하면서 도중에 미리 조금 정리를 해둔 것들만 cp/ps 했다.


Warm Up (Easy)

1
2
3
4
5
6
7
8
9
10
//SPDX-License-Identifier: MIT
pragma solidity >= 0.7.0 < 0.9.0;

contract Warmup {
    string public flag = "flag{FAKE_FLAG}";

    function Callme() public view returns(string memory) {
        return flag;
    }
}

그냥 flag 변수 읽거나, Callme() 함수 호출하면 된다.


Over 16 (Easy)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract Over_16 {
    mapping(address => uint16) public balances;
    uint16 private originalBalance;

    constructor() public {
        originalBalance = 21436;
        balances[msg.sender] = originalBalance;
    }

    function add_16(uint _value) public {
        balances[msg.sender] += uint16(_value);
    }

    function get16_Flag() public returns (string memory) {
        require(balances[msg.sender] == 16, "XXXXXXXXXXXXXXXX");
        balances[msg.sender] = originalBalance;
        return "flag{FAKE_FLAG}";
    }

    function getBalance() public view returns (uint16) {
        return balances[msg.sender];
    }
}

add_16() 함수를 이용해 잔액을 16으로 만들면 된다.


Access Control (Medium)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract AccessControll{
    address public owner;

    CertificateAuthority CA;
    mapping(address => bool) grantedUsers;
    mapping(address => uint256) public securityLevel;
    
    constructor ( address _owner) {
        owner = _owner;
    }

    function accessRequest(address payable  _CA)public {
        CA = CertificateAuthority(_CA);
        bool success = CA.verify(msg.sender);
        if(success) grantedUsers[msg.sender] = true;
        
    }

    function setSecurityLevel( address user, uint256 level)public payable  {
        require(grantedUsers[user] == true, "You need Permission to raise the security level!!");
        (bool success, bytes memory result) = address(CA).delegatecall(abi.encodeWithSignature("setLevel(address,address,uint256)",owner,  user,level));
        require(success, "Delegatecall failed");
        uint256 levelToSet;
        assembly {
            levelToSet := mload(add(result, 0x20))
        }

        securityLevel[user] = levelToSet;
    }

    function flag()public view returns (string memory){
        require(grantedUsers[msg.sender] == true, "You need Permission to get the flag!");
        require(securityLevel[msg.sender] == 5, "Your level must be 5 to get the flag!");
        return "flag{FAKE_FLAG}";
    }

    receive() external payable  {

    }

}

contract CertificateAuthority{
    mapping(address => bool) grantedUsers;

    constructor() {   }

    function verify(address user) public payable  returns (bool){
        if(msg.value > 1000000000000000000000 ){
            grantedUsers[user] = true;
            return true;
        }
        else if (grantedUsers[user] == true){ 
            return true;
        }
        return false;
    }

    function setLevel(address owner, address toSet, uint256 level)public returns(uint256){
        require(msg.sender == owner);
        require(grantedUsers[toSet] == true);
        
            level = level << 3;
            level = level ^ 0xD9;
            level = level & 0x03;

        return level;
    }

     receive() external payable { 
        
        require(msg.value > 1000000000000000000000);
        
     }
}

이 문제의 플래그 획득 조건은 함수 호출자의 권한이 부여되어 있어야 하고, 보안 레벨이 5이면 된다. 먼저 권한 설정은 accessRequest() 함수에서 진행된다. accessRequest() 함수 내부를 보면 전달받은 주소를 이용해서 외부 컨트랙트를 불러오고, 이 컨트랙트의 verify() 함수를 호출 하는데, 이 함수의 반환 값이 true이면 권한이 설정된다. 이후에 보안 레벨은 setSecurityLevel() 함수에서 설정할 수 있는데, accessRequest() 함수에서 불러온 외부 컨트랙트로 delegatecall()를 이용하여 setLevel() 함수를 호출한다. setLevel() 함수의 반환 값으로 mload(add(result, 0x20))로 하여서 설정할 레벨이 계산된다. mload(add(result, 0x20))는 result 바이트 배열의 시작 위치에서 32바이트를 읽어온 값을 반환한다. 그러니 result의 값이 abi.encode(uint(5))가 반환되게 하면 mload()에 의해 5를 읽어와 보안 레벨이 5로 될 것인다.

mload() 함수는 어셈블리 코드 내에서 사용되며, 특정 메모리 주소로부터 워드(32바이트) 단위의 데이터를 읽어와 변수에 할당하거나 계산에 사용된다.

1
2
3
4
5
function setLevel(address owner, address toSet, uint256 level) public returns(bytes memory){
	bytes memory result = abi.encode(uint(5));
	console2.logBytes(result);
    return result;
}

그래서 위와 같이 setLevel() 함수를 만들어서 레벨을 5로 설정 하려고 해보았는데, delegatecall() 함수의 반환 값인 result를 출력해보면 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000005와 같이 이상한 더미 데이터가 포함되어 레벨이 항상 0x20:32로 설정되는 것을 확인할 수 있었다. 그래서 그냥 이를 바이트로 안 주고, uint로 5를 반환해주니 잘 됐다.

delegatecall()로 특정 함수 호출 시에 그 함수가 바이트 타입을 반환할 경우 추가 정보에 대한 바이트가 함께 딸려온다. 기본 사양인지.. 나만 이러는 건지.. 일단 메모


Mamma Mia! (Medium)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

contract MammaMia {
    mapping(address => uint) public balances;
    address public flagCapturer;
    mapping(address => bool) public flagResetters;

    function deposit() public payable {
        require(msg.value > 0, "Deposit must be greater than zero");
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        require(!flagResetters[msg.sender], "The flag resetter is not allowed to withdraw");
        uint bal = balances[msg.sender];
        require(bal > 0, "No balance to withdraw");

        (bool sent, ) = msg.sender.call{value: bal}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function captureFlag() public {
        require(address(this).balance == 0, "Contract balance is not zero");
        require(flagCapturer == address(0), "Flag has already been captured");

        flagCapturer = msg.sender;
    }

    function resetFlag() public payable {
        require(flagCapturer == msg.sender, "You are not the flag capturer");
        require(msg.value >= 0.001 ether, "Please add balance to the contract for someone else");

        flagResetters[msg.sender] = true;
        flagCapturer = address(0);
    }

    function getFlag() public view returns (string memory) {
        require(flagResetters[msg.sender], "You are not the flag resetter");

        return "flag{FAKE_FLAG}";
    }
}

플래그를 얻기 위한 조건은 flagResetters가 참이어야 한다. flagResetters의 값은 resetFlag() 함수를 통해서 참으로 만들 수 있는데, 함수를 사용하기 위해서는 flagCapturer이 현재 함수를 호출한 사용자이고, msg.value는 0.001 ether 보다 크거나 같아야 한다. flagCapturer는 captureFlag() 함수에서 만들 수 있는데, 이 함수를 사용하기 위해서는 컨트랙트의 잔액을 0으로 만들어야 한다. 그러나 withdraw() 함수를 보면 msg.sender.call() 함수로 돈을 내보낸 이후에 잔액을 0으로 만들고 있기 때문에 Reentrancy 취약점이 발생한다. 이를 이용해서 잔액을 0으로 만들고, 조건을 맞춘 이후에 플래그를 얻으면 된다.


Safe Deposit Box (Hard)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
pragma solidity ^0.8.0;
// SPDX-License-Identifier: MIT
contract SafeDepositBox {
    
    address owner;
    constructor () {
        owner = msg.sender;
        balances[owner] = 2147483647;
    }
    struct Transaction {
        address to;
        address from;
        uint amount;

    }
    uint private state_num = 0;
    mapping(uint => Transaction[]) public userTransactions;
    mapping(address => uint) public balances;   

    string private secret_password = unicode"REDACTED";

    modifier fill_money() {
        if (balances[owner] < 10000) {
            balances[owner] = 2147483647;
        }
        _;
    }
    function make_account() public returns (address){
        balances[msg.sender] = 1000;
        return msg.sender;
    }

    function introduction_safe_deposit_box() public pure returns (string memory) {
        return "This is a safe asset management service. We haven't been hacked in the last 10 years.";
    }

    function safe_remittance_function(string memory password, address _to,uint _amount) external returns (uint){
        require(keccak256(abi.encodePacked(password)) == keccak256(abi.encodePacked(secret_password)), "Incorrect password");
        require(balances[msg.sender] >= _amount);
        address _from = msg.sender;
        balances[_from] -= _amount;
        balances[_to] += _amount;
        Transaction memory newTransaction = Transaction({
            to: _to,
            from: _from,
            amount: _amount
        });
        uint hash = uint(keccak256(abi.encodePacked(block.timestamp)));
        userTransactions[hash].push(newTransaction);
        return hash;
    }

    function cancel_transaction(uint _hash) public fill_money{
        require(userTransactions[_hash].length > 0, "No transaction with this hash");
        Transaction storage transactionToCancel = userTransactions[_hash][0];
        require(transactionToCancel.from == msg.sender, "You are not the sender of this transaction");
        balances[transactionToCancel.from] += transactionToCancel.amount;
        balances[transactionToCancel.to] -= transactionToCancel.amount;
    }

    function buy_flag() public returns (string memory) {
        require (balances[msg.sender] > 100000, "Not Enough money.");
        require (msg.sender != owner,"No Hack.");
        balances[msg.sender] = 0;
        string memory flag = return_flag();
        return flag;
    }
    
    function return_flag() internal returns (string memory) {
        return unicode"flag{REDACTED}";
    }
}

Safe Deposit Box 문제의 플래그 획득 조건은 잔액을 100000 이상으로 만들면 된다. make_account() 함수를 보면 함수를 호출한 사람의 잔액을 1000으로 초기화 해주는데 이 함수에 대해서 횟수 제한이 없다. 그럼 make_account() 함수로 잔액을 1000으로 초기화하고, safe_remittance_function() 함수로 임의의 사용자에게 전송하면 임의의 사용자의 잔액은 1000이 되고, 나의 잔액은 0이 될 것이다. 그럼 또 다시 make_account() 함수로 잔액을 1000으로 만들고 다시 임의의 사용자로 전송하를 반복해서 트랜잭션 내에 총 100000만큼 포함되게 만든 이후에 해당 트랜잭션을 캔슬하면 트랜잭션 내에 쌓인 모든 잔액을 나의 잔액으로 되돌릴 수 있다. 그러나 safe_remittance_function() 함수의 사용 조건을 보면 비밀번호가 필요하다. secret_password는 keccak256(3)keccak256(3) + 1에 위치해 있다. 이 비밀번호를 획득하고, 위에서 설명한 과정을 수행하여 나의 잔액을 100000 이상으로 만들고 플래그를 획득하면 된다.

일반적으로는 컨트랙트의 상태 변수들이 순차적으로 배치되어 연속된 슬롯에 저장될 것으로 예상된다. 하지만 최적화 및 보안상의 이유로, Solidity 컴파일러는 상태 변수의 배치를 단순한 순차적인 방식이 아니라 더 복잡한 방식으로 결정할 수 있고, 이로 인해 상태 변수의 배치가 예상과 다를 수 있다.


BMW Bugbounty (Hard)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//SPDX-License-Identifier : MIT
pragma solidity ^0.8.0;

import "./process.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract BMW {
    address private owner;
    BMW_process private processContract;

    constructor () {
        processContract = new BMW_process(address(this));
        owner = msg.sender;
    }

    modifier OnlyOwner {
        require(msg.sender == owner, "Your not owner");
        _;
    }

    function change_owner(address _owner) external OnlyOwner{
        owner = _owner;
    }

    function search_address() external view returns(address) {
        return address(processContract);
    }


    function flag() external returns(string memory){
        require(processContract.check_my_nft(msg.sender) > 10000, "Enough BMW NFT");

        processContract.reset_account();

        return "Exploit-Success!!";
    }

}

총 2개의 파일이 제공되는데 위 코드는 NFT.sol라는 파일의 코드이다. 그러나 ERC-721가 아닌 ERC-20를 사용하고 있다. 플래그 획득 조건을 보면 10000 이상의 NFT를 소유하고 있으면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
//SPDX-License-Identifier : MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract BMW_process is ERC20{
    address private guardian;
    mapping (address => mapping (address => uint256)) private _allowance;
    mapping (address => uint256) private balance;

    constructor (address _guardian) ERC20("BMW_NFT", "BMW") {
        _mint(msg.sender, 1000);
        guardian = _guardian;
    }

    modifier OnlyGuardian {
        require(msg.sender == guardian, "Your not guardian");
        _;
    }

    function change_guardian(address _guardian) public OnlyGuardian {
        guardian = _guardian;
    }

    function mint() public {
        _mint(msg.sender, 10);
        balance[msg.sender] = balanceOf(msg.sender);
    }

    function Buy_nft(uint256 _count) external payable returns(uint256) {
        for(uint256 i; i < _count; i++) {
            require(msg.value >= 1 ether, "Not enough Ether");
            balance[msg.sender] = balanceOf(msg.sender);
            _mint(msg.sender, balance[msg.sender] + 1);

            if (balance[msg.sender] > 10000) {

                return balance[msg.sender];
            }
        }

        return balance[msg.sender];
    }

    function nfttransfer(address _receipt, uint256 _amount) external returns(bool) {
        require(balance[msg.sender] > _amount, "Not enough BMW NFT");
        require(_allowance[msg.sender][_receipt] > _amount, "Not enough allowance");
        require(balance[msg.sender] > balance[msg.sender] - _amount, "Detected integer underflow");
        require(_allowance[msg.sender][_receipt] < _allowance[msg.sender][_receipt] + _amount, "Detected integer overflow");

        super._transfer(msg.sender, _receipt, _amount);
        
        return true;
    }

    function get_allowance(address _from, address _to, uint256 _amount) external OnlyGuardian returns(bool) {
        require(_allowance[_from][_to] < _allowance[_from][_to] + _amount, "detected integer overflow");
        _allowance[_from][_to] += _amount;
    }

    function check_my_nft(address _target) public view returns(uint256) {
        return balance[_target];
    }

    function check_allowance(address _from, address _to) public view returns(uint256) {
        return _allowance[_from][_to];
    }

    function reset_account() external OnlyGuardian {
        _mint(msg.sender, 0);
    }
}

process.sol 파일은 위와 같다.Buy_nft() 함수를 보면 매우 이상한 점이 있다. msg.value가 1 ether 이거나 이상이라면 _count만큼 토큰 잔액을 _count + 1 만큼 계속 더하는 것을 볼 수 있다. 즉, 1 ether만 있으면 토큰을 무한으로 만들 수 있다. 그리고 for loop가 돌면서 토큰 잔액이 10000보다 클 경우 그냥 종료해버리기 때문애 _count로 큰 정수를 넘기면 10000 이상의 토큰을 얻을 수 있다. 이후에 그냥 플래그 읽으면 된다. 뭐지?

This post is licensed under CC BY 4.0 by the author.