st98 の日記帳


[ctf][zer0pts] BSides Ahmedabad CTF 2021 で出題した問題の解説

(English version: https://hackmd.io/@st98/By9ZkjNDK)

11 月 6 日から 11 月 7 日にかけて、チーム zer0pts は BSides Ahmedabad CTF 2021 を開催しました。66 点以上得点したチームは 314 チームでした。ご参加ありがとうございました。

1 位は 4032 点を獲得した ./Vespiary、2 位は 3685 点を獲得した Black Bauhinia、3 位は 2902 点を獲得した Thehackerscrew でした。おめでとうございます🎉

zer0pts/bsides-ahmedabad-ctf-2021 で問題のソースコードなどが公開されていますので、問題に挑戦してみたいとか、リベンジを果たしたいといった方はぜひ遊んでみてください。

競技時間中に解かれなかった s1r1us さん作問の neutron, bettercatalog の 2 問については、それぞれ最初に解いた方に 50 ドルの賞金が与えられるそうなのでぜひ挑んでみてください。 → どちらも解かれたそうです。すごい。s1r1us さんによる writeup も公開されているので、ぜひ読んでみてください。

The CTF is over, Neutron(ElectronJS) and bettercatalog(1day XS-leak) are still unsolved.

So, we are extending challenges for a week. Get the first blood and submit the writeup, and you will get a chance to win $50/each.

More details about the challenges:https://t.co/m5cSwA8EQS https://t.co/nNLe3veITB pic.twitter.com/WcK8tes8P9

— s1r1us (@S1r1u5_) November 7, 2021

この記事では、出題された 23 問のうち私が作問した以下の 1 問について解説します。

[Web 200] Roda (21 solves)

I made an image uploader. It is easy to use; just drag and drop, and share the URL with your friends!

If you find a suspicious image, please report it to the admin. The admin will check it.

(URL)

添付ファイル: roda_1fcc5923ea213c01886ae2d67a509689.tar.gz

問題の概要

画像をドラッグ&ドロップでアップロードできる Web アプリケーションが与えられています。でかでかと書かれているメッセージから、JPEG もしくは PNG のアップロードしか受け付けていないらしいことがわかります。

トップページ

画像をアップロードすると、/WfteW6oJ のようなランダムに生成された URL に遷移します。これは画像に割り当てられたパーマリンクです。

このページでは <img> タグで /uploads/(UUID).(jpg|png) にアップロードされた画像が埋め込まれており、ページ下部には admin に報告できるボタンがあります。このボタンを押すと、admin が Google Chrome でこのページを見に来ます。

adminに報告できるボタンがある

フラグの場所を確認しましょう。与えられているソースコードを確認すると、Cookie の secret というキーに入っている値が特定の文字列 (SECRET という環境変数に入っている文字列) であれば、/flag にアクセスすると表示されるらしいことがわかります。その「特定の文字列」は推測できないものなので、実質的には admin しかフラグを見られないということになります。

const SECRET = process.env.SECRET || 's3cr3t';
const FLAG = process.env.FLAG || 'Neko{dummy}';

// ...

// admin only!
function adminRequired(req, res, next) {
  if (!('secret' in req.cookies)) {
    res.status(401).render('error', {
      message: 'Unauthorized'
    });
    return;
  }

  if (req.cookies.secret !== SECRET) {
    res.status(401).render('error', {
      message: 'Unauthorized'
    });
    return;
  }

  next();
}

// ...

app.get('/flag', adminRequired, (req, res) => {
  res.send(FLAG);
});

ファイルのチェックをバイパスする

ファイルのアップロード処理を確認し、どのようにして JPEG と PNG だけをアップロードできるようにしているか把握しましょう。

/upload のハンドラを見てみると、まずアップロードされたファイルの内容と、オリジナルのファイル名から拡張子とを取り出していることがわかります。その後 isValidFile という関数にファイルの内容と拡張子を渡し、その返り値によってアップロードされたファイルをそのまま保管するかどうかを決めています。

const upload = multer({
  storage,
  limits: {
    files: 1,
    fileSize: 100 * 1024
  }
});
app.post('/upload', upload.single('file'), (req, res) => {
  const { file } = req;
  fs.readFile(file.path, (err, data) => {
    const buf = new Uint8Array(data);

    const fileName = file.originalname;
    const ext = fileName.split('.').slice(-1)[0];
  
    // check if the file is safe
    if (isValidFile(ext, buf)) {
      const newFileName = uuidv4() + '.' + ext;
      fs.writeFile('uploads/' + newFileName, buf, (err, data) => {
        let id;
        do {
          id = generateId();
        } while (id in uploadedFiles);

        uploadedFiles[id] = newFileName;
        res.json({
          status: 'success',
          id
        });
      });
    } else {
      res.json({
        status: 'error',
        message: 'Invalid file'
      });
    }
  });
});

isValidFile は以下のような実装になっています。与えられた拡張子に応じて SIGNATURES で定義されたシグネチャを取り出し、ファイルの内容がそのシグネチャから始まっているかを確認しています。

ファイルの内容が Uint8Array であるためか、シグネチャとファイルの内容の比較は compareUint8Arrays という独自の関数で行われています。それぞれの長さが一致しているか確認した後に、1 バイトずつ比較しています。

実はこのチェックには問題があります。SIGNATURESMap でなく Object であるために、もし拡張子が toStringvalueOf といった Object に存在するメソッド名であれば、signature にはそれに対応する関数が入ります。

関数は引数の数を返す length プロパティを持つので、もし拡張子が valueOf であれば signature.length は 0 になります。これによって compareUint8Arrays では known.length !== input.length はいずれも 0 になるために常に false になりますし、その後のループもスキップされます。したがって、その返り値は常に true になります。

つまり、valueOf のような拡張子にすれば JPEG か PNG でないファイルもアップロードできます。

const SIGNATURES = {
  'png': new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
  'jpg': new Uint8Array([0xff, 0xd8])
};

function compareUint8Arrays(known, input) {
  if (known.length !== input.length) {
    return false;
  }

  for (let i = 0; i < known.length; i++) {
    if (known[i] !== input[i]) {
      return false;
    }
  }

  return true;
}

function isValidFile(ext, data) {
  // extension should not have special chars
  if (/[^0-9A-Za-z]/.test(ext)) {
    return false;
  }

  // prevent uploading files other than images
  if (!(ext in SIGNATURES)) {
    return false;
  }

  const signature = SIGNATURES[ext];
  return compareUint8Arrays(signature, data.slice(0, signature.length));
}

XSS に持ち込みたい

JPEG や PNG 以外のファイルもアップロードできることがわかりましたが、これは悪用できるようなことなのでしょうか。ソースコードを確認しつつ考えていきましょう。

アップロード後のファイルがどのように扱われるか確認します。以下に抜粋した箇所は、リクエストされたパスに対応するファイルの内容を返す処理にあたります。まず ../ のような危険な文字列がパスに含まれているか、あるいはリクエストされたファイルが存在していなければ、HTML でエラーを表示します。どちらにも当てはまらなければ、ファイルの拡張子に対応する Content-Type ヘッダを設定した上で、ファイルの内容を返します。

もし valueOf のような拡張子であればどうなるのでしょうか。MIME_TYPESSIGNATURES と同様に Object なので、MIMES_TYPES[ext] は関数を返します。res.type は引数が関数である場合には対応していないためにエラーが発生し、res.type(MIME_TYPES[ext]); は失敗します。これより前に res.type('text/html'); が実行されているために、Content-Typetext/html に設定されます。

つまり、拡張子を valueOf などにすれば、それが HTML として表示されるということになります。XSS チャンス!

// show uploaded contents
const MIME_TYPES = {
  'png': 'image/png',
  'jpg': 'image/jpeg'
};

app.get('/uploads/:fileName', (req, res) => {
  const { fileName } = req.params;
  const path = 'uploads/' + fileName;

  // no path traversal
  res.type('text/html'); // prepare for error messages
  if (/[/\\]|\.\./.test(fileName)) {
    res.status(403).render('error', {
      message: 'No hack'
    });
    return;
  }

  // check if the file exists
  try {
    fs.accessSync(path);
  } catch (e) {
    res.status(404).render('error', {
      message: 'Not found'
    });
    return;
  }

  // send proper Content-Type header
  try {
    const ext = fileName.split('.').slice(-1)[0];
    res.type(MIME_TYPES[ext]);
  } catch {}

  fs.readFile(path, (err, data) => {
    res.send(data);
  });
});

試しに以下のような内容のファイルを test.valueOf というファイル名でアップロードします。/uploads/(UUID).valueOf にアクセスするとアラートが表示されました。あとは /flag の内容を取得するようなスクリプトに変えて、admin に踏ませるだけです。

<script>
alert(123);
</script>

admin への報告

あとは admin に URL を報告するだけと思いきや、以下のコードを見るとわかるように報告できる URL は制限されていることがわかります。/uploads/(UUID).valueOf/report に POST するだけでは、パスが /:id/report にマッチしないらしく 404 が返ってきてしまいます。

実は / にパーセントエンコーディングを施せばバイパスできます。/uploads%2f(UUID).valueOf/report に POST すればパスは /:id/report にマッチし、req.params.id には uploads/(UUID).valueOf が入ります。

// report image to admin
app.post('/:id/report', async (req, res) => {
  const { id } = req.params;
  const { token } = req.query;
/*
  const params = `?secret=${RECAPTCHA_SECRET_KEY}&response=${encodeURIComponent(token)}`;
  const url = 'https://www.google.com/recaptcha/api/siteverify' + params;
  const result = await axios.get(url);

  if (!result.data.success) {
    res.json({
      status: 'error',
      message: 'reCAPTCHA failed'
    });
    return;
  }
*/
  redis.rpush('query', id);
  redis.llen('query', (err, result) => {
    console.log('[+] reported:', id);
    console.log('[+] length:', result);
    res.json({
      status: 'success',
      length: result
    });
  })
})

以下のスクリプトを適当な画像表示ページで実行し、報告ボタンが押されたときに実行される処理を置き換えます。このまま報告ボタンを押すと、admin によって指定した URL にフラグが POST されました。

async function onSubmit(token) {
  const button = document.getElementById('recaptcha');
  button.disabled = true;

  const result = await (await fetch('/uploads%2f(UUID).valueOf/report?token=' + token, {
    method: 'POST'
  })).json();
  button.textContent = '\u{1f6a9}Thanks for the report! Queue length: ' + result.length;
  button.disabled = true;
}
Neko{S4EoMHlo608?t=2m30s}

画像のアップローダなので「ろだ」です。フラグは YouTube の動画 ID と再生位置です。問題の内容との関連はまったくありませんが。いい感じのフラグが思いつかなかったので、ちょうどその時バックグラウンドで再生していた動画の URL から持ってきました。かわいいなあ。

関数オブジェクトにも length プロパティが生えているという話は TSG Live! CTF 6 の問題で既出ですし、ほかの細々としたネタもググるなり色々試すなりしていれば、そのうち見つかって解けるんじゃないかなと思いながら作りました。

このエントリーをはてなブックマークに追加
st98.github.io / st98 の日記帳