All Articles

Pico CTF 2025 Writeup

This page has been machine-translated from the original page.

For this year’s picoCTF, I only solved the Rev challenges for now.

There were not many particularly interesting challenges this time, but I will leave a brief writeup anyway.

Table of Contents

Flag Hunters(Rev)

Lyrics jump from verses to the refrain kind of like a subroutine call. There’s a hidden refrain this program doesn’t print by default. Can you get it to print it? There might be something in it for you.

The following Python script was provided as the challenge file.

import re
import time


# Read in flag from file
flag = open('flag.txt', 'r').read()

secret_intro = \
'''Pico warriors rising, puzzles laid bare,
Solving each challenge with precision and flair.
With unity and skill, flags we deliver,
The ether’s ours to conquer, '''\
+ flag + '\n'


song_flag_hunters = secret_intro +\
'''

[REFRAIN]
We’re flag hunters in the ether, lighting up the grid,
No puzzle too dark, no challenge too hid.
With every exploit we trigger, every byte we decrypt,
We’re chasing that victory, and we’ll never quit.
CROWD (Singalong here!);
RETURN

[VERSE1]
Command line wizards, we’re starting it right,
Spawning shells in the terminal, hacking all night.
Scripts and searches, grep through the void,
Every keystroke, we're a cypher's envoy.
Brute force the lock or craft that regex,
Flag on the horizon, what challenge is next?

REFRAIN;

Echoes in memory, packets in trace,
Digging through the remnants to uncover with haste.
Hex and headers, carving out clues,
Resurrect the hidden, it's forensics we choose.
Disk dumps and packet dumps, follow the trail,
Buried deep in the noise, but we will prevail.

REFRAIN;

Binary sorcerers, let’s tear it apart,
Disassemble the code to reveal the dark heart.
From opcode to logic, tracing each line,
Emulate and break it, this key will be mine.
Debugging the maze, and I see through the deceit,
Patch it up right, and watch the lock release.

REFRAIN;

Ciphertext tumbling, breaking the spin,
Feistel or AES, we’re destined to win.
Frequency, padding, primes on the run,
Vigenère, RSA, cracking them for fun.
Shift the letters, matrices fall,
Decrypt that flag and hear the ether call.

REFRAIN;

SQL injection, XSS flow,
Map the backend out, let the database show.
Inspecting each cookie, fiddler in the fight,
Capturing requests, push the payload just right.
HTML's secrets, backdoors unlocked,
In the world wide labyrinth, we’re never lost.

REFRAIN;

Stack's overflowing, breaking the chain,
ROP gadget wizardry, ride it to fame.
Heap spray in silence, memory's plight,
Race the condition, crash it just right.
Shellcode ready, smashing the frame,
Control the instruction, flags call my name.

REFRAIN;

END;
'''

MAX_LINES = 100

def reader(song, startLabel):
  lip = 0
  start = 0
  refrain = 0
  refrain_return = 0
  finished = False

  # Get list of lyric lines
  song_lines = song.splitlines()
  
  # Find startLabel, refrain and refrain return
  for i in range(0, len(song_lines)):
    if song_lines[i] == startLabel:
      start = i + 1
    elif song_lines[i] == '[REFRAIN]':
      refrain = i + 1
    elif song_lines[i] == 'RETURN':
      refrain_return = i

  # Print lyrics
  line_count = 0
  lip = start
  while not finished and line_count < MAX_LINES:
    line_count += 1
    for line in song_lines[lip].split(';'):
      if line == '' and song_lines[lip] != '':
        continue
      if line == 'REFRAIN':
        song_lines[refrain_return] = 'RETURN ' + str(lip + 1)
        lip = refrain
      elif re.match(r"CROWD.*", line):
        crowd = input('Crowd: ')
        song_lines[lip] = 'Crowd: ' + crowd
        lip += 1
      elif re.match(r"RETURN [0-9]+", line):
        lip = int(line.split()[1])
      elif line == 'END':
        finished = True
      else:
        print(line, flush=True)
        time.sleep(0.5)
        lip += 1



reader(song_flag_hunters, '[VERSE1]')

Accessing the remote server causes the text after [VERSE1] to be printed in order.

Because the Flag is written on lines before [VERSE1], we need to somehow make the introductory text print.

When you connect to the remote server, it asks for input at CROWD, and you can see that the text you enter is appended to the server-side data by song_lines[lip] = 'Crowd: ' + crowd.

Furthermore, if the text matches RETURN [0-9]+, execution jumps to the line specified there and continues printing.

Also, because the text is evaluated using semicolon-separated tokens, if you provide input so that a line like Crowd: ;RETURN 0 is added, you can make it print the correct Flag as shown below.

image-20250308164121021

Tap into Hash(Rev)

Can you make sense of this source code file and write a function that will decode the given encrypted file content? Find the encrypted file here. It might be good to analyze source file to get the flag.

Looking at the Python script provided as the challenge file, you can see that it performs a simple encryption scheme.

import time
import base64
import hashlib
import sys
import secrets

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

key = b"\x1br\t;\x0f\xb5\x9f\xaa\xd1'\xaf\x86[\xf0\xe6\xd9'D\xf9\x8d\x17g\xeb>_gG.\xd4\xc3\xdc\x83"
enc = b'o\x14>\xda\x16\xc7\xce\xd784,.\x8f2\x80@cD?\xd3L\x90\x9f\x87l0yy\xdam\x85J1\x139\x88\x10\x95\x9f\x82ke*.\xda>\xd3\x195O?\xd3\x10\xc4\x94\x83kd| \x882\x86IzFk\xd2@\x95\xcd\x83:by{\x8c3\x81\x1e6E8\xdaC\xc2\xcf\xd087||\x8em\xd0\x1c3Cj\xde\x10\xc0\x99\x89;4+{\x8fn\x84\x1abN9\xdc\x16\xc5\xc8\xd0mc}+\x81o\xd7J1[k\xda\x12\x94\xce\x89m3}(\xdbh\x84KeDh\x8fF\x9e\xc9\x84i1.*\x8f2\x87\x1d4\x15+\x83\x17\xc9\xef\xe5Iz*t\xd7h\xd9\'d%\t\x82"\xcf\xfe\xd3[09{\xe0T\xea-=;k\x98@\x9f\xcf\xf9Pp\x0bb\xd5A\xe8\x02\x15=\x04\x8eL\x96\x9f\x86n0ze\x89m\x81A6Bi\x88B\x91\xce\x8279q~\x8d:\x84OfAn\x8fA\x95\xc9\xd76d|}\x95;\x82@oFb\xddE\x93\x98\xd2jdz/\x88h\xd7\x1a6@o\xdd\x12\x94\x94\xd2m`}y\x8c>\x82LaCm\x8f\x10\x9f\x9f\xd3>0py\xde2\x87AoGh\x88\x11\x91\x9a\x84=3z*\xde&\x82Hb@n\xddM\xc3\xce\x89me.(\x808\xd0KaGo\x8bG\x91\x9b\x81?`.|\xde=\xd6@b\x17m\x8e\x11\x9f\x95\x80>d+.\x88>\x80\x1d4\x14m\xdd\x12\x96\xcf\x85m1q-\x8ao\xb0z'

decrypted_text = b""
block_size = 16
key_hash = hashlib.sha256(key).digest()

for i in range(0, len(enc), block_size):
    block = enc[i:i + block_size]
    decrypt_block = xor_bytes(block, key_hash)
    decrypted_text += decrypt_block

By running the solver above, you can easily decrypt the Flag.

image-20250308165823543

Chronohack(Rev)

Can you guess the exact token and unlock the hidden flag? Our school relies on tokens to authenticate students. The access is granted through nc verbal-sleep.picoctf.net 61959. Unfortunately, someone leaked an important file for token generation. Guess the token to get the flag.

The following script was provided as the challenge file.

import random
import time

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

def flag():
    with open('/flag.txt', 'r') as picoCTF:
        content = picoCTF.read()
        print(content)


def main():
    print("Welcome to the token generation challenge!")
    print("Can you guess the token?")
    token_length = 20  # the token length
    token = get_random(token_length) 

    try:
        n=0
        while n < 50:
            user_guess = input("\nEnter your guess for the token (or exit):").strip()
            n+=1
            if user_guess == "exit":
                print("Exiting the program...")
                break
            
            if user_guess == token:
                print("Congratulations! You found the correct token.")
                flag()
                break
            else:
                print("Sorry, your token does not match. Try again!")
            if n == 50:
                print("\nYou exhausted your attempts, Bye!")
    except KeyboardInterrupt:
        print("\nKeyboard interrupt detected. Exiting the program...")

if __name__ == "__main__":
    main()

It turns out that you can obtain the Flag if you guess the one-time password generated by a PRNG seeded with the timestamp at runtime within the attempt limit.

I was able to get the Flag with the following simple script.

import random
import time
from pwn import *

def get_random(T):
    alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
    random.seed(T)
    s = ""
    for i in range(20):
        s += random.choice(alphabet)
    return s.encode()

for i in range(0,1000,30):
    s0 = int(time.time() * 1000)
    target = remote("verbal-sleep.picoctf.net", 59978, ssl=False)
    # s1 = int(time.time() * 1000)
    target.recvuntil(b"Welcome to the token generation challenge!")
    s1 = int(time.time() * 1000)
    target.recvuntil(b"Enter your guess for the token (or exit):")
    s3 = int(time.time() * 1000)
    print(s0,s1,s1-s0,s3-s1)

    target.sendline(get_random(0))
    r = target.recvline()

    j = 0
    while(j < 47):
        tmp = j+i
        target.recvuntil(b"Enter your guess for the token (or exit):")
        tk = get_random(s0+tmp)
        target.sendline(tk)
        try:
            r = target.recvline()
            print(s0+tmp, tk, r)
            if "Congratulations" in r.decode():
                print(r)
                r = target.recvline()
                print(r)
                exit()
        except:
            print(s1+tmp,tmp)
            print(r)
            print(target.recv)

        j += 1

    target.clean()

image-20250309073016466

However, perhaps something was wrong with the challenge server: at first I could not get the correct Flag no matter what I tried, and I wasted a lot of time on it.

The next morning the server had been updated, and when I used the same script as the day before, I was able to get the Flag right away.

Quantum Scrambler(Rev)

We invented a new cypher that uses “quantum entanglement” to encode the flag. Do you have what it takes to decode it?

Checking the provided script showed that it shuffled the byte value of each character in the Flag using a custom algorithm.

The processing was not very complex, so I simply wrote a solver that reversed the shuffling and obtained the Flag.

scrambled_L = [スクランブル化されたリスト]

i = len(scrambled_L)
while(i > 2):
    i -= 1
    im2 = scrambled_L[i-1].pop()
    im1 = scrambled_L[i-2].pop()
    scrambled_L[i-1].append(im1)

print(scrambled_L)

# [['0x70'], ['0x63', '0x69'], ['0x43', '0x6f'], ['0x46', '0x54'], ['0x70', '0x7b'], ['0x74', '0x79'], ['0x6f', '0x68'], ['0x5f', '0x6e'], ['0x73', '0x69'], ['0x77', '0x5f'], ['0x69', '0x65'], ['0x64', '0x72'], ['0x35', '0x62'], ['0x31', '0x37'], ['0x32', '0x34'], ['0x66', '0x66'], ['0x7d']]

for l in scrambled_L:
   if len(l) == 1:
      print(chr(int(l[0], 16)), end="")
    
   if len(l) == 2:
      print(chr(int(l[1], 16)), end="")
      print(chr(int(l[0], 16)), end="")

# picoCTF{python_is_weirdb57142ff}

perplexed(Rev)

Download the binary here.

Analyzing the challenge binary showed that it validates the input password with the following check function.

int64_t check(char* input)

{
  if (strlen(input) != 0x1b)
      return 1;

  int64_t val;
  __builtin_memcpy(&val, "\xe1\xa7\x1e\xf8\x75\x23\x7b\x61\xb9\x9d\xfc\x5a\x5b\xdf\x69\xd2\xfe\x1b\xed\xf4\xed\x67\xf4", 0x17);
  int32_t n = 0;
  int32_t k = 0;
  int32_t var_2c_1 = 0;

  for (int32_t i = 0; i <= 0x16; i += 1)
  {
      for (int32_t j = 0; j <= 7; j += 1)
      {
          if (!k)
              k += 1;

          int32_t rax_17;
          rax_17 = ((int32_t)input[(int64_t)n] & 1 << (7 - k)) > 0;

          if (rax_17 != (int8_t)(((int32_t)*(uint8_t*)(&val + (int64_t)i) & 1 << (7 - j)) > 0))
              return 1;

          k += 1;

          if (k == 8)
          {
              k = 0;
              n += 1;
          }

          if ((int64_t)n == strlen(input))
              return 0;
      }
  }

  return 0;
}

Since it was essentially simple byte manipulation, I could find the Flag easily by just throwing angr at it.

import angr

proj = angr.Project("perplexed", auto_load_libs=False)
obj = proj.loader.main_object
print("Entry", hex(obj.entry))

find = 0x40143e
avoids = [0x40143e]

init_state = proj.factory.entry_state()
simgr = proj.factory.simgr(init_state)
simgr.explore(find=find, avoid=avoids)

# 出力
simgr.found[0].posix.dumps(0)

The execution result is below.

image-20250308194125700

Binary Instrumentation 1(Rev)

I have been learning to use the Windows API to do cool stuff! Can you wake up my program to get the flag?

Running the challenge binary showed that it was implemented to sleep forever before printing the correct Flag.

image-20250308222217123

When I analyzed the binary in the decompiler, I noticed that it had an unusual entry point and might be packed with some kind of packer.

int64_t _start()
{
  TEB* gsbase;
  struct _PEB* ProcessEnvironmentBlock = gsbase->ProcessEnvironmentBlock;
  int64_t lpMem = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x400);

  if (GetLastError() != ERROR_IPSEC_IKE_SECLOADFAIL)
      HeapFree(GetProcessHeap(), HEAP_NONE, lpMem);
  else
  {
      ReleaseSRWLockExclusive(nullptr);
      ReleaseSRWLockShared(nullptr);
      SetCriticalSectionSpinCount(nullptr, 0);
      TryAcquireSRWLockExclusive(nullptr);
      WakeAllConditionVariable(nullptr);
      SetUnhandledExceptionFilter(nullptr);
      UnhandledExceptionFilter(nullptr);
      CheckMenuItem(nullptr, 0, 0);
      GetMenu(nullptr);
      GetSystemMenu(nullptr, 0);
      GetMenuItemID(nullptr, 0);
      EnableMenuItem(nullptr, 0, MF_BYCOMMAND);
      MessageBeep(MB_OK);
      GetLastError();
      MessageBoxW(nullptr, nullptr, nullptr, MB_OK);
      MessageBoxA(nullptr, nullptr, nullptr, MB_OK);
      UpdateWindow(nullptr);
      GetWindowContextHelpId(nullptr);
  }

  if (ProcessEnvironmentBlock && ProcessEnvironmentBlock->OSMajorVersion == 0xa)
  {
      int64_t i = 0;
      arg_10 = nullptr;
      arg_8 = 0;
      void* ImageBaseAddress = ProcessEnvironmentBlock->ImageBaseAddress;
      int64_t rsi_1 = (int64_t)*(uint32_t*)((char*)ImageBaseAddress + 0x3c);
      void* rbx_1 = (char*)ImageBaseAddress + 0x108 + rsi_1;

      do
      {
          if (sub_14b0(rbx_1) == 0x9f520b2d)
          {
              uint64_t rdi_1 = (uint64_t)*(uint32_t*)((char*)rbx_1 + 0xc);
              uint64_t rbx_2 = (uint64_t)*(uint32_t*)((char*)rbx_1 + 0x10);

              if (rdi_1 != -(ImageBaseAddress) && rbx_2 && sub_18b0() && !sub_1300(1, rdi_1 + ImageBaseAddress, (uint64_t)rbx_2, &arg_10, &arg_8))
              {
                  loader(arg_10, arg_8);
                  return 0;
              }

              break;
          }

          rbx_1 += 0x28;
          i += 1;
      } while (i <= (uint64_t)*(uint16_t*)(rsi_1 + ImageBaseAddress + 6));
  }

  return 0xffffffff;
}

Looking at the PE sections confirmed that a section named ATOM had been created, which showed that the binary had been packed with a packer called AtomPePacker.

image-20250308222301491

Reference: NUL0x4C/AtomPePacker: A Highly capable Pe Packer

Unfortunately, as far as I could tell after searching around, no unpacker for this Packer had been published, so I decided to extract the unpacked binary after it had been loaded into memory.

Reading a bit further into the binary, I found that the following loader function validates the PE header, which strongly suggested that the unpacked PE file data had already been expanded in memory by this point.

image-20250308222509137

So I set a breakpoint on this function in WinDbg and ran the program.

bp PP64Stub+1dc0

Next, I dumped the unpacked PE that had been loaded in memory at that point with the .writemem command.

.writemem C:\Users\kash1064\Downloads\bininst1\dump.exe @rcx L?0x6499

Analyzing the dumped program confirmed that it contained a Base64-encoded Flag string, as shown below.

image-20250308222156904

That gave me the correct Flag.

image-20250308222244757

Binary Instrumentation 2(Rev)

I’ve been learning more Windows API functions to do my bidding. Hmm… I swear this program was supposed to create a file and write the flag directly to the file. Can you try and intercept the file writing function to see what went wrong?

Analyzing the challenge binary confirmed that it had been packed in the same way as the previous binary.

So I used WinDbg and the same method as before to dump the unpacked PE data.

bp PP64Stub+1dc3
g

.writemem C:\Users\kash1064\Downloads\dump.exe @rcx L?0x6499

Decompiling the dumped binary showed that it embedded a Base64-encoded Flag in the binary, as shown below, which gave me the correct Flag.

image-20250308232722925

image-20250308232741279

Since this challenge could be solved with exactly the same approach as the previous one, with no twist at all, I was not quite sure what the intended idea was.

Summary

It was a bit disappointing that the Rev challenges were not very interesting.

I think I will try some other categories too.