Just CTF: Baby Otter
Baby Otter
A while ago I participated in Just CTF, I remembered I should post at least once every month in this blog, so here we go.
The CTF had a Solana challenge, which I enjoy solving since they are normally very puzzle-like.
A quick summary of the challenge is to crack an internal encryption and call request_ownership
method with the cracked code as a parameter.
Analysis
Solana challenges usually provide two folders: framework
and framework-solve
.
Our solution goes into framework-solve
(surprise).
Challenge Code
Inside framework folder, there are two Rust codes to observe: main.rs
and baby_otter_challenge.rs
. main
is the driver code and baby_otter_challenge
is a method that the main uses and the one that we are going to exploit.
main.rs
looks like:
use std::env;
use std::fmt;
use std::thread;
use std::mem::drop;
use std::path::Path;
use std::error::Error;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use sui_ctf_framework::NumericalAddress;
use sui_transactional_test_runner::args::SuiValue;
use sui_transactional_test_runner::test_adapter::FakeID;
fn handle_client(mut stream: TcpStream) -> Result<(), Box<dyn Error>> {
// Initialize SuiTestAdapter
let chall = "baby_otter_challenge";
let named_addresses = vec![
("challenge".to_string(), NumericalAddress::parse_str("0x8107417ed30fcc2d0b0dfd680f12f6ead218cb971cb989afc8d28ad37da89467")?),
("solution".to_string(), NumericalAddress::parse_str("0x42f5c1c42496636b461f1cb4f8d62aac8ebac3ca7766c154b63942671bc86836")?),
];
let precompiled = sui_ctf_framework::get_precompiled(Path::new(&format!(
"./chall/build/{}/sources/dependencies",
chall
)));
let mut adapter = sui_ctf_framework::initialize(
named_addresses,
&precompiled,
Some(vec!["challenger".to_string(), "solver".to_string()]),
);
let mut solution_data = [0 as u8; 1000];
let _solution_size = stream.read(&mut solution_data)?;
// Publish Challenge Module
let mod_bytes: Vec<u8> = std::fs::read(format!(
"./chall/build/{}/bytecode_modules/{}.mv",
chall, chall
))?;
let chall_dependencies: Vec<String> = Vec::new();
let chall_addr = sui_ctf_framework::publish_compiled_module(&mut adapter, mod_bytes, chall_dependencies, Some(String::from("challenger")));
println!("[SERVER] Challenge published at: {:?}", chall_addr);
// Publish Solution Module
let mut sol_dependencies: Vec<String> = Vec::new();
sol_dependencies.push(String::from("challenge"));
let sol_addr = sui_ctf_framework::publish_compiled_module(&mut adapter, solution_data.to_vec(), sol_dependencies, Some(String::from("solver")));
println!("[SERVER] Solution published at: {:?}", sol_addr);
let mut output = String::new();
fmt::write(
&mut output,
format_args!(
"[SERVER] Challenge published at {}. Solution published at {}",
chall_addr.to_string().as_str(),
sol_addr.to_string().as_str()
),
).unwrap();
stream.write(output.as_bytes()).unwrap();
// Prepare Function Call Arguments
let mut args_sol : Vec<SuiValue> = Vec::new();
let arg_ob = SuiValue::Object(FakeID::Enumerated(1, 1));
args_sol.push(arg_ob);
// Call solve Function
let ret_val = sui_ctf_framework::call_function(
&mut adapter,
sol_addr,
"baby_otter_solution",
"solve",
args_sol,
Some("solver".to_string())
);
println!("[SERVER] Return value {:#?}", ret_val);
println!("");
// Check Solution
let mut args2: Vec<SuiValue> = Vec::new();
let arg_ob2 = SuiValue::Object(FakeID::Enumerated(1, 1));
args2.push(arg_ob2);
let ret_val = sui_ctf_framework::call_function(
&mut adapter,
chall_addr,
chall,
"is_owner",
args2,
Some("challenger".to_string()),
);
println!("[SERVER] Return value {:#?}", ret_val);
println!("");
// Validate Solution
match ret_val {
Ok(()) => {
println!("[SERVER] Correct Solution!");
println!("");
if let Ok(flag) = env::var("FLAG") {
let message = format!("[SERVER] Congrats, flag: {}", flag);
stream.write(message.as_bytes()).unwrap();
} else {
stream.write("[SERVER] Flag not found, please contact admin".as_bytes()).unwrap();
}
}
Err(_error) => {
println!("[SERVER] Invalid Solution!");
println!("");
stream.write("[SERVER] Invalid Solution!".as_bytes()).unwrap();
}
};
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
// Create Socket - Port 31337
let listener = TcpListener::bind("0.0.0.0:31337")?;
println!("[SERVER] Starting server at port 31337!");
// Wait For Incoming Solution
for stream in listener.incoming() {
match stream {
Ok(stream) => {
println!("[SERVER] New connection: {}", stream.peer_addr().unwrap());
thread::spawn(move|| handle_client(stream).unwrap());
}
Err(e) => {
println!("[SERVER] Error: {}", e);
}
}
}
// Close Socket Server
drop(listener);
Ok(())
}
Nothing too significant, we can see that the driver code will try to verify whether the client is “owner” and print flag if yes.
baby_otter_challenge.rs
looks like:
module challenge::baby_otter_challenge {
// [*] Import dependencies
use std::vector;
use sui::object::{Self, UID};
use sui::transfer;
use sui::tx_context::{TxContext};
// [*] Error Codes
const ERR_INVALID_CODE : u64 = 31337;
// [*] Structs
struct Status has key, store {
id : UID,
solved : bool,
}
// [*] Module initializer
fun init(ctx: &mut TxContext) {
transfer::public_share_object(Status {
id: object::new(ctx),
solved: false
});
}
// [*] Local functions
fun gt() : vector<u64> {
let table : vector<u64> = vector::empty<u64>();
let i = 0;
while( i < 256 ) {
let tmp = i;
let j = 0;
while( j < 8 ) {
if( tmp & 1 != 0 ) {
tmp = tmp >> 1;
tmp = tmp ^ 0xedb88320;
} else {
tmp = tmp >> 1;
};
j = j+1;
};
vector::push_back(&mut table, tmp);
i = i+1;
};
table
}
fun hh(input : vector<u8>) : u64 {
let table : vector<u64> = gt();
let tmp : u64 = 0xffffffff;
let input_length = vector::length(&input);
let i = 0;
while ( i < input_length ) {
let byte : u64 = (*vector::borrow(&mut input, i) as u64);
let index = tmp ^ byte;
index = index & 0xff;
tmp = tmp >> 8;
tmp = tmp ^ *vector::borrow(&mut table, index);
i = i+1;
};
tmp ^ 0xffffffff
}
// [*] Public functions
public entry fun request_ownership(status: &mut Status, ownership_code : vector<u8>, _ctx: &mut TxContext) {
let ownership_code_hash : u64 = hh(ownership_code);
assert!(ownership_code_hash == 1725720156, ERR_INVALID_CODE);
status.solved = true;
}
public entry fun is_owner(status: &mut Status) {
assert!(status.solved == true, 0);
}
}
Here we can see a few interesting methods: gt(), hh(), and request_ownership().
We want to trigger request_ownership
with the correct ownership_code_hash value (specifically, 1725720156), so the state of status.solved
will be true.
Exploit
Function hh() and gt() is essentially a Rust implementation of CRC32 (this took 6 hours to figure it out amongst my team, but the grind was worth it).
We simply ran crc32 decryptor to reverse 1725720156 (0x66dc665c in hex):
python2 crc32.py reverse 0x66dc665c
4 bytes: H4CK {0x48, 0x34, 0x43, 0x4b}
verification checksum: 0x66dc665c (OK)
6 bytes: 6g9CPP (OK)
6 bytes: 7gxrKI (OK)
6 bytes: 9hgBwG (OK)
6 bytes: DgMXYM (OK)
6 bytes: JhRheC (OK)
6 bytes: MqUVOh (OK)
6 bytes: Qok67t (OK)
6 bytes: ZY9zyX (OK)
6 bytes: bmw2Rn (OK)
6 bytes: bq8nSz (OK)
6 bytes: cqy_Hc (OK)
6 bytes: uYTBeJ (OK)
The key turns out to be H4CK.
We finally supply the above key as hex string: x”4834434b” and call request_ownership
with it to get flag.
Finalized solve script solve.rs
:
module solution::baby_otter_solution {
use sui::tx_context::TxContext;
use challenge::baby_otter_challenge;
public entry fun solve(status: &mut baby_otter_challenge::Status, ctx: &mut TxContext) {
let str = x"4834434b";
baby_otter_challenge::request_ownership(status,str,ctx);
}
}
We lastly run the given script to execute client code to get flag:
set -eux
cd framework-solve/solve && sui move build
cd ..
cargo r --release
The build took a while (2hr) but we got the flag!
Connection Output: '[SERVER] Challenge published at 8107417ed30fcc2d0b0dfd680f12f6ead218cb971cb989afc8d28ad37da89467. Solution published at 42f5c1c42496636b461f1cb4f8d62aac8ebac3ca7766c154b63942671bc86836'
Connection Output: '[SERVER] Congrats, flag: '
Thanks!
079