魚脳の池

CTF:Little Twoos

SECCON Beginners CTF 2020 writeup

SECCON Beginners 2020 writeup

f:id:Gyonou:20200526031510p:plain 同期と一緒にチーム Little Twoosとして参加しました,チーム内では「普段取り組めないジャンルを挑戦する」という方針を予め決めており,自分は今回暗号ではなく,リバーシングをメインに解いてみました.

rev

mask

root@ae548dfbc2cd:/CTF/2020/ctf4b/mask# ./mask
Usage: ./mask [FLAG]
root@ae548dfbc2cd:/CTF/2020/ctf4b/mask# ./mask abcdefg
Putting on masks...
a`adede
abc`abc
Wrong FLAG. Try again.

まずはバイナリを動かしてみる,自分は時々回すこと忘れ,慌ててghidraとか開けちゃう癖があるので,そろそろ自分なりのアプローチ方法を決めなきゃ...
本題に戻ると,どうやら[FLAG]を与えるとそれが正解かどうかを検証してくれるプログラムらしい.あと出力の中に特に真ん中の二行が気になります.
ghidraでバイナリを開き,main関数をデコンパイルして仮のc言語のコードに戻します.

undefined8 main(int iParm1,long lParm2){

...
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  if (iParm1 == 1) {
    puts("Usage: ./mask [FLAG]");
  }
  else {
    strcpy((char *)flag,*(char **)(lParm2 + 8)); //flagを受け取り
    fLen = strlen((char *)flag); //flagの長さ
    fLen_int = (int)fLen;
    puts("Putting on masks...");
    count = 0;
    while (count < fLen_int) {
      local_98[(long)count] = flag[(long)count] & 0x75;// flag文字ごと0x75と論理積を取る
      local_58[(long)count] = flag[(long)count] & 0xeb;// flag文字ごと0xebと論理積を取る
      count = count + 1;
    }
    local_98[(long)fLen_int] = 0;
    local_58[(long)fLen_int] = 0;
    puts((char *)local_98);
    puts((char *)local_58);
    iVar1 = strcmp((char *)local_98,"atd4`qdedtUpetepqeUdaaeUeaqau");//0x75と論理積を取った文字列を検証
    if ((iVar1 == 0) &&
       (iVar1 = strcmp((char *)local_58,"c`b bk`kj`KbababcaKbacaKiacki"), iVar1 == 0)) {//0xebと論理積を取った文字列を検証
      puts("Correct! Submit your FLAG.");
    }
    else {
      puts("Wrong FLAG. Try again.");
    }
  }
  if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
    return 0;
  }
...
}

上のコードは多少ghidraと違いがあります(デコンパイルwindowでL押せば書き換える).
コメントどおり,第2引数のflagは片方文字ごと0x75と論理積をとってatd4...と一致するのかを検証.
片方文字ごと0xebと論理積を取ってcbbk...と一致するのかを検証.
よって,方針としては:

 
FLAG\ \&\ chr(0x75)*len=atd4...\\
FLAG\ \&\ chr(0xeb)*len=c'bb...

この2つの式を満たすflagを当てにう\行くことです.

ここで最近revのwriteupで見かけたz3を使って計算しました.

from z3 import *


l = 29
mask1 = 0x75
mask2 = 0xeb
ct1 = b'atd4`qdedtUpetepqeUdaaeUeaqau'
ct2 = b'c`b bk`kj`KbababcaKbacaKiacki'


solver = Solver()
flag = [BitVec('b%i' %i,8) for i in range(l)]
m1 = [BitVec('b%i' %i,8) for i in range(l)]
m2 = [BitVec('b%i' %i,8) for i in range(l)]

for i in range(l):
    m1[i] = flag[i] & mask1
    m2[i] = flag[i] & mask2

    solver.add(m1[i] == ct1[i])
    solver.add(m2[i] == ct2[i])
    solver.add(And(flag[i]>=33,flag[i]<=126))


solver.add(flag[0] == ord("c"))
solver.add(flag[1] == ord("t"))
solver.add(flag[2] == ord("f"))


if solver.check() == sat:
    m = solver.model()
    print(''.join([chr(m[f].as_long()) for f in flag]))

z3はMMAさんのwikiを参考しながら書いてみた.
上のコードは2つの等式に基づき,更に色々制限条件を付け加えました,これによって解くスピードが変わります(多分).
ここでz3使うのが多少オーバーキルかもしれませんが,論理演算自分はちょっと苦手なので,確実な方法を選びました.

yakisoba

まずは下調べです.
とりあえずchecksecでセキュリティ機構を確認した.

checksec yakisoba
[*] '/CTF/2020/ctf4b/yakisoba/yakisoba/yakisoba'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    FORTIFY:  Enabled

結構対策されてる様子.次はghidraに投げてデコンパイルしてみた.

ulong FUN_00100680(void)

{
  int flag;
  uint uVar1;
  long in_FS_OFFSET;
  byte input [40];
  long local_20;
  
  uVar1 = 1;
  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  __printf_chk(1,"FLAG: ");
  flag = __isoc99_scanf(&DAT_001010fb,input);
  if (flag != 0) {
    uVar1 = check_flag(input);
    if (uVar1 == 0) {
      puts("Correct!");
    }
    else {
      uVar1 = 0;
      puts("Wrong!");
    }
  }
  if (local_20 == *(long *)(in_FS_OFFSET + 0x28)) {
    return (ulong)uVar1;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

どうやら読み取ったflagをcheck_flag(実際デコンパイルされた関数名は違う)関数でチェックしてるようで,更にcheck_flag関数に飛んでいくと,めちゃくちゃ長いif文が現れて,ちょっとだけ読んでみたが,すぐ眠くなったので,main関数とcheck_flag関数のコードを全文コピーして新しいcコードを書き,コンパイルしたバイナリをangrに投げる作戦に変更.

import angr

project = angr.Project('./problem',auto_load_libs=False)

state = project.factory.entry_state()
simgr = project.factory.simulation_manager(state)
simgr.explore(find=0x0401282,avoid=0x040128e)
state = simgr.found[0]

print(state.posix.dumps(0))

↑はangrのコードですが,もう一回gdbやghidraを開き,puts("Correct!");puts("Wrong!");のアドレスを調べる.
そしてfindのところにputs("Correct!");のアドレスを入れ,avoidのところにputs("Wrong");のアドレスを入れれば「avoidのアドレスまで行かず,findのアドレスまで実効できる」ちょうどいい引数をangrが自動に探してくれるコードになります,自分も1,2回しか使ったことがなかったので,詳しい使い方はまだ勉強する必要がある.

ghost

なんか検索したらpostscriptだと判明.
仕様書?的なものをよんで,頑張って半分ぐらいまでコードを読んだが,睡魔が再び襲わってきたので,とりあえずそのまま動かしてctf4b{を入力したら,結果が3417 61039 39615 14756 10315 49836まさか配布されたoutput.txt最初の結果と一致したことが気づき,bruteforceにした.

from pwn import *
from string import printable

output = '3417 61039 39615 14756 10315 49836 44840 20086 18149 31454 35718 44949 4715 22725 62312 18726 47196 54518 2667 44346 55284 5240 32181 61722 6447 38218 6033 32270 51128 6112 22332 60338 14994 44529 25059 61829 52094'
output = output.split(' ')

flag = 'ctf4b{'
for idx in range(6,len(output)):
    for x in printable:
        io = process(['/usr/bin/gs','chall.gs'])
        io.sendline(flag+x)
        io.recvlines(3)
        r = io.recvline().strip().split(' ')[-1]
        if r == output[idx]:
            flag += x
            print(flag)
            break
        else:
            io.close()

subprocessが久しぶりすぎて,書き方忘れたのでpwntoolで無理やり書いてみました,とりあえずctf4b{をベースにして後ろに一文字ずつ追加してgs chall.gsに投げて,その結果はあってるいるのかを確認したコードになります.
ここから余談ですが,postscriptに気になって,一晩明けにもう一回読んでみました.

/flag 64 string def 
/output 8 string def 
(%stdin) (r) file flag readline 
not { (I/O Error\n) print quit } if 
0 1 
2 index 
length { 1 index 1 add 3 index 3 index get xor mul 1 
    463 { 1 index mul 64711 mod } repeat 
    exch pop dup 
    output cvs print ( ) print 
    128 mod 1 add exch 1 add exch } repeat 
(\n) print quit

無理矢理にインデントと開業をつけて少し読みやすくなると思う.
上のコードは
1. flagとoutputというstring型の変数を定義
2. 標準入力からflagを入手 (stack: flag,true)
3. 「trueになってるのかを検証し,popする」的な操作
4. push 0 push 1
5. 2 indexはスタックtopからカウントして2番目(topのindexは0)をまたpushするらしい(stack: flag 0 1 flag)
6. length{...}repeat...操作をlength回を実行する意味で一回おいておきます.
7. 肝心なloop内の内容ですが,コメントつけてみた

1 index 1 add 3 index 3 index get xor mul 1 463 { 1 index mul 64711 mod } repeat 
# get使ってflagの一文字ずつ取り出す,ここは便宜上iにします.
# flag[i]はまずi+1とxorをとり,その結果をされに463乗にしてmodulus 64711にした

exch pop dup 
# いらないものをpop

output cvs print ( ) print 
# 上の計算結果をcvsでstringにcastしprintした,後ろに空白を更に追加

128 mod 1 add exch 1 add exch 
# modulus 128は謎だが,残り i+=1的なものです.

上のpostscriptをpythonに書き換えてみたら

for i in range(len(flag)):
    t = ord(flag[i])^(i+1)
    x = str(pow(t,463,64711))
    print x

これがこれで解けるはずです.

siblangs

SECCON珍しくandroidの問題です,最近割とandroidを勉強したため,意地でも解けたい一心で取り組んだ.
実際apkをインストールしてみたら,どうやらflagを入力してvalidate AとValidate Bでflagをチェックしてるたった一つのactivityのアプリらしい
さらにソースコードの抽出については,apkファイルに対して個人的にapktoolsをunzip両方をやった,その違いと目的は以下のようになります.

  • apktools
    • androidmanifest.xmlが読める
    • valueとかのsourceファイルは見れる
  • unzip
    • dex-(dex2jar)->jar-(unzip)->.classによってjavaで読める

やったこと:

  • androidmanifest.xmlを読んで,どうやらReactNativeで書かれたことがわかる
  • 入力欄に予めctf4bが表示されたのでそれをvscodeにctrl+shift+fでctf4bを検索した.
    そこに難読化されたjsファイルが発見
   function v() {
    var t;
    (0, l.default)(this, v);
    for (var o = arguments.length, n = new Array(o), c = 0; c < o; c++) n[c] = arguments[c];
    return (t = y.call.apply(y, [this].concat(n))).state = {
        flagVal: "ctf4b{",
        xored: [34, 63, 3, 77, 36, 20, 24, 8, 25, 71, 110, 81, 64, 87, 30, 33, 81, 15, 39, 90, 17, 27]
    }, t.handleFlagChange = function(o) {
        t.setState({
            flagVal: o
        })
    }, t.onPressValidateFirstHalf = function() {
        if ("ios" === h.Platform.OS) {
            for (var o = "AKeyFor" + h.Platform.OS + "10.3", l = t.state.flagVal, n = 0; n < t.state.xored.length; n++)
                if (t.state.xored[n] !== parseInt(l.charCodeAt(n) ^ o.charCodeAt(n % o.length), 10)) return void h.Alert.alert("Validation A Failed", "Try again...");
            h.Alert.alert("Validation A Succeeded", "Great! Have you checked the other one?")
        } else h.Alert.alert("Sorry!", "Run this app on iOS to validate! Or you can try the other one :)")
    }, t.onPressValidateLastHalf = function() {
        "android" === h.Platform.OS ? p.default.validate(t.state.flagVal, function(t) {
            t ? h.Alert.alert("Validation B Succeeded", "Great! Have you checked the other one?") : h.Alert.alert("Validation B Failed", "Learn once, write anywhere ... anywhere?")
        }) : h.Alert.alert("Sorry!", "Run this app on Android to validate! Or you can try the other one :)")
    }, t}

どうやらflagはッッ前半後半に分かれて検証されてるみたい,後半は訳わからなかったので,とりあえず置いといて,前半はflagとAkeyios10.3とxorを取ってるのが丸見えでした.

   output = 'AKeyForios10.3'
    l = len(output)
    flag = []
    xored = [34, 63, 3, 77, 36, 20, 24, 8, 25, 71, 110, 81, 64, 87, 30, 33, 81, 15, 39, 90, 17, 27]
    for idx in range(len(xored)):
        tmp = ord(output[idx%l]) ^ xored[idx]
        flag.append(tmp)
    print ''.join(map(chr,flag))
↑のpythonコードで復元できるはず

後半はこのjsと関係なさそうなんで,javaソースコードを見てみるとValidateFlagModule.classはあやしい.
AES使って暗号化してその結果を検証してるみたいなコードっぽい,しかも比較する結果やkeyまであるので,それをそのまま実行してみたら,flagはそのまま出た

    import java.util.*;
    import javax.crypto.Cipher;
    import javax.crypto.SecretKey;
    import javax.crypto.spec.GCMParameterSpec;
    import javax.crypto.spec.SecretKeySpec;

    public class Main {
        public static void main(String[] args) throws Exception {
        // Your code here!
            SecretKey secretKey = new         SecretKeySpec("IncrediblySecure".getBytes(), 0, 16, "AES");
            byte[] arrayOfByte = new byte[43];
            arrayOfByte[0] = 95;
            arrayOfByte[1] = -59;
            arrayOfByte[2] = -20;
            arrayOfByte[3] = -93;
            arrayOfByte[4] = -70;
            arrayOfByte[5] = 0;
            arrayOfByte[6] = -32;
            arrayOfByte[7] = -93;
            arrayOfByte[8] = -23;
            arrayOfByte[9] = 63;
            arrayOfByte[10] = -9;
            arrayOfByte[11] = 60;
            arrayOfByte[12] = 86;
            arrayOfByte[13] = 123;
            arrayOfByte[14] = -61;
            arrayOfByte[15] = -8;
            arrayOfByte[16] = 17;
            arrayOfByte[17] = -113;
            arrayOfByte[18] = -106;
            arrayOfByte[19] = 28;
            arrayOfByte[20] = 99;
            arrayOfByte[21] = -72;
            arrayOfByte[22] = -3;
            arrayOfByte[23] = 1;
            arrayOfByte[24] = -41;
            arrayOfByte[25] = -123;
            arrayOfByte[26] = 17;
            arrayOfByte[27] = 93;
            arrayOfByte[28] = -36;
            arrayOfByte[29] = 45;
            arrayOfByte[30] = 18;
            arrayOfByte[31] = 71;
            arrayOfByte[32] = 61;
            arrayOfByte[33] = 70;
            arrayOfByte[34] = -117;
            arrayOfByte[35] = -55;
            arrayOfByte[36] = 107;
            arrayOfByte[37] = -75;
            arrayOfByte[38] = -89;
            arrayOfByte[39] = 3;
            arrayOfByte[40] = 94;
            arrayOfByte[41] = -71;
            arrayOfByte[42] = 30;
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            GCMParameterSpec gCMParameterSpec = new GCMParameterSpec(128, arrayOfByte, 0, 12);
            cipher.init(2, secretKey, gCMParameterSpec);
            arrayOfByte = cipher.doFinal(arrayOfByte, 12, arrayOfByte.length - 12);
            String flag = new String(arrayOfByte);
            System.out.println(flag);
        }
    }

web

spy

最初全然わからなかった,チームメンバーから「ユーザによってloginのresponseを受けるまでの時間がかなり異なる場合がある」というヒントをもらい,もう一回ソースコード見てみると,dbに存在んしたuserだと謎のcalc_password_hashによって返すまでの時間がましたことがわかり,とりあえず全ユーザのusernameを投げて,画面の一番下に書いてた所要時間とペアで出力して,ダントツで時間がかかったuserをマーク提出したらflagが降ってきた

# coding: utf-8
import requests
import subprocess
import re

with open('employees.txt') as f:
    em = f.read().strip().split('\n')

url = 'https://spy.quals.beginners.seccon.jp/'

for name in em:
    payload = {'name':name,'password':'abcdefghijklmn'}
    r = requests.post(url,data=payload).text
    t = re.findall(r'\d+\.\d\d\d\d\d\d\d',r)
    print(t,name)

crypto

Noisy equations

最後ほかのチームメンバーはおそらくpwnのhardに集中してると思い,残ったcrypto問題を解いてみた.

from os import getenv
from time import time
from random import getrandbits, seed


FLAG = getenv("FLAG").encode()
SEED = getenv("SEED").encode()

L = 256
N = len(FLAG)


def dot(A, B):
    assert len(A) == len(B)
    return sum([a * b for a, b in zip(A, B)])

coeffs = [[getrandbits(L) for _ in range(N)] for _ in range(N)]

seed(SEED)

answers = [dot(coeff, FLAG) + getrandbits(L) for coeff in coeffs]

print(coeffs)
print(answers)

問題のソースコードと結果からわかったこととしては:

  • coeffsの長さは44ー>flagの長さも44
  • getrandbitsは怪しい
  • ノイズは二箇所
    • dot(coeff,FLAG) -> flag文字ごとに○倍にしてその和をとる
    • + getrandbits(L)-> 一回seedでgetrandbitsをresetした -> 毎回同じ

これから自分の下手くそ日本語解説より直接式を見たほうがわかりやすいと思う


R_{1}=sum(Matrix_{coeffs1}*Vector_{flag})+noise\\
R_{2}=sum(Matrix_{coeffs2}*Vector_{flag})+noise\\
Diff_{coeffs}=Matrix_{coeffs1}-Matrix_{coeffs2}\\
Diff_{R} = R1-R2\\
Diff_{coeffs}*Vector_{flag} = Diff_{R}

あとは連立方程式みたいに求めるとflagが出た,今回はまたせっかくなのでsageを使いました.

from sage.all import *


with open('cache') as f:
    ct = f.read().strip().split('\n')

coeffs1 = eval(ct[0])
answers1 = eval(ct[1])


with open('cache1') as f:
    ct = f.read().strip().split('\n')

coeffs2 = eval(ct[0])
answers2 = eval(ct[1])


def minus(A,B):
    return [a-b for a,b in zip(A,B)]


diff = minus(answers1,answers2)
diff_coeffs = [minus(x,y) for x,y in zip(coeffs1,coeffs2)]


left = Matrix(QQ,diff_coeffs)
right = vector(diff)

sol = left.solve_right(right)
show(sol)
print(''.join(map(chr,sol)))

所感

久しぶりに同期と一緒にワイワイCTFをやってて楽しかったです.同期たちが強すぎて,自分ももうちょっと精進しないと...