R·ex / Zeng


音遊狗、安全狗、攻城獅、業餘設計師、段子手、苦學日語的少年。

使用 PhantomJS 來實現 CTF 中的 XSS 題目

Update 20180607:Puppeteer 的版本請移步 這裡


零:CTF、XSS 的概念

CTF 在這個部落格中提到的已經很多了,它是一類資訊保安競賽,在比賽中,選手透過各種方式,從題目給出的檔案或者網址中,獲取到某一特定格式的字串。

CTF 中比較常見的一個題型就是 XSS(跨站指令碼攻擊),大概的原理就是服務端沒有正確過濾使用者的輸入,導致使用者提交的畸形字串被插入到網頁中並被解析成 JavaScript 執行。在 CTF 中,XSS 一般用來拿管理員的 Cookie,偽造身份登入後臺,再進行後續的滲透(順便提一下,現在大部分網站的敏感 Cookie 都被設成了 HTTP Only,因此 XSS 是沒法拿到的,需要用其它的方法)。

一個非常簡單的反射型 XSS 注入如下(為了突出重點,我就不把頁面寫的這麼完整了,一般的 CTF 題目也鮮有很符合規範的頁面):

<html>
<body>
Hello <?php echo $_GET['name']; ?>!
</body>
</html>

如果我們輸入的網址中,name 引數值為 rex<script>alert(1)</script>,那麼整個網頁會變成這樣:

<html>
<body>
Hello rex<script>alert(1)</script>!
</body>
</html>

頁面上就會有一個彈框。當然,如果能成功 alert(1),那麼一般來說大概應該可能有其它方法來獲取 Cookie,因此比較簡單的 XSS 的檢測方式通常是看頁面上能否 alert(1)。

當然,XSS 還有其它方法,例如在一個論壇上發帖內容為 <img src=# onerror=alert(1)>,而這個論壇也沒做輸入過濾,那麼這段惡意程式碼就會一直保留在這個帖子裡,基本每個點進來的人都會遭殃。此為儲存型 XSS。

就算服務端做了一些過濾,駭客也可能會繞過。例如服務端的過濾如下:

function escape($str) {
    return preg_replace('/<script>/', '', $str);
}

想繞過的話,只需要使用 <scr<script>ipt>alert(1)</script> 即可,左邊被過濾之後剩下的剛好又拼接成了一個 <script> 標籤。

有一個很好玩的網站:alert(1) to win,是我在大一的時候某隻姓三的學長給我的。這個網站給了你 escape 函式,你的目標就是輸入 input,使其透過 escape 函式之後依舊可以 alert 出數字 1(注意是數字 1,不是字串 1)。這個網站的題目對於目前的我來說還是比較難的,如果大家有興趣,可以去挑戰一下。

一:PhantomJS 的概念

我之前對電腦的認識是非常膚淺的。第一次聽說虛擬機器居然還可以跑在命令列下的時候,我心想:虛擬機器軟體本身沒有圖形介面,那你該怎麼顯示虛擬機器裡面的圖形呢?後來特麼又看到了 PhantomJS,居然是個沒有圖形介面的瀏覽器!當時還心想,這玩意又沒法給人看,會有啥用啊……

後來接觸了爬蟲之後才逐漸理解了這玩意的用途。它是一個透過命令列和 API 操作的、沒有圖形介面的瀏覽器,專注於自動化測試、爬蟲等不需要人們瀏覽,但需要獲取資料的一些場合。

如果覺得 PhantomJS 官方的文件太多懶得看,針對一些簡單的程式設計,看阮老師的這篇文章也可以:PhantomJS -- JavaScript 標準參考教程(alpha)

二:基於 PhantomJS 的 CTF-XSS-Checker 的實現

我的思路大概是參照了上面的網站實現的,但是上面的 escape 函式是返回了一個過濾之後的字串,而我打算直接用 eval 方法。

先放一下介面好啦!可以看到,上面網站中的 escape 函式被我改成了 check,裡面會有一句 eval

0

由於 Node.js 與 PhantomJS 的互動最為簡單,因此後端使用 Node.js 來編寫。思路其實很簡單:啟動一個伺服器,針對前端的靜態檔案直接返回檔案內容(當然,這一點也可以用 Nginx 代勞),針對題目生成對應的題目網頁,針對 /check 路由根據 POST body 進行 XSS 判斷。

具體的路由邏輯我就不寫了,畢竟即使不會開伺服器,不會寫路由,使用 koa 等框架也能很輕鬆地實現。這裡重點說一下前後端的檢驗流程。寫一個網頁直譯器實在是太難,而且也不值得,所以最簡單的方法就是不如就讓它 alert 成功,只不過我們修改一下 alert 函式罷了。

前端先生成一個隱藏的 iframe,透過劫持裡面的 onerrorconsole.logalert 等函式來處理,透過 HTML5 Message API 在父頁面和 iframe 之間傳遞資訊。具體程式碼如下:

window.onerror = function (a) {
    parent.postMessage({
        error: a.toString()
    }, "*");
};

window.console = window.console || {};
window.console.log = function (a) {
    parent.postMessage({
        console: a
    }, "*");
};

window.alert = function (a) {
    if (a === 1)
        parent.postMessage({
            success: 1
        }, "*");
    else if (a == 1)
        parent.postMessage({
            warning: "You should alert *NUMBER* 1."
        }, "*");
    else {
        parent.postMessage({
            warning: "You need to alert 1."
        }, "*");
    }
};

window.onmessage = function (a) {
    try {
        check(a.data);
    } catch(e) {
        parent.postMessage({
            error: e.toString().split("\\n")[0]
        }, "*");
    };
};

然後父頁面透過返回的資料來處理就可以了,例如 onerror 的時候就將下面的黃條變紅,並顯示傳過來的資訊,如果 success 了,就將資料發給服務端進行驗證。程式碼如下:

// script 就是上面說的要嵌入 iframe 裡面的程式碼
iframe.src = 'data:text/html,' + encodeURIComponent(problemText.replace(/\n\s*/g, '')) + script;
iframe.onload = function () {
    this.contentWindow.postMessage(textarea.value, '*');
};

// 父頁面透過 iframe 傳回來的資訊進行相應的處理
window.onmessage = function (e) {
    var d = e.data;
    console.log(d);
    if (d.success !== undefined) {
        tab.className = 'rs-tab rs-tab-success';
        tab.innerText = 'Local check passed, running server check...';
        // server check
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function () {
            if (xhr.readyState === 4) {
                if (xhr.status === 200) {
                    tab.innerText = 'Server response: \'' + xhr.responseText + '\'.';
                }
            }
        };
        xhr.open('POST', '/check', true);
        xhr.send(JSON.stringify({
            id: location.pathname.match(/^\/(\d+)$/)[1],
            ans: textarea.value,
        }));
    } else if (d.warning !== undefined) {
        tab.className = 'rs-tab rs-tab-warning';
        tab.innerText = d.warning;
    } else if (d.error !== undefined) {
        tab.className = 'rs-tab rs-tab-danger';
        tab.innerText = d.error;
        output.innerText = '';
    } else if (d.console !== undefined) {
        output.innerText = d.console;
    }
};

這樣本地的檢驗就可以啦!去看看服務端的 /check 是怎麼寫的。由於服務端是接收 JSON 返回 JSON 的,因此如果出了結果,直接輸出一段 JSON 即可。假設我們已經想辦法獲取到了使用者輸入(上面那段程式碼中的 ans)、檢驗函式(之前提到的 check),那麼可以這樣寫:

var input = /* 獲取到的 ans */;
var outputStr = '';

function output(obj) {
    outputStr = JSON.stringify(obj);
}

window.onerror = function (a) {
    output({ error: a.toString() });
}

window.alert = function (a) {
    if (a === 1) {
        output({ success: 1 });
    } else if (a == 1) {
        output({ error: "You should alert *NUMBER* 1." });
    } else {
        output({ error: "Server check failed, you need to alert 1." });
    }
};

/* 在這兒注入 check 函式的實現 */

try {
    check(input);
} catch (e) {
    output({ error: e.toString().split("\\n")[0] });
} finally {
    return outputStr;
}

說了這麼多流程,終於要用到 PhantomJS 啦!我們需要用它建立一個頁面,執行上面的程式碼,獲取返回結果,並且在使用者提交耗資源的操作(例如死迴圈)時及時將其關閉。

var phantom = require('phantom');
    var phInstance = null;
    var exitted = false;
    phantom.create()
        .then(instance => {
            phInstance = instance;
            return instance.createPage();
        })
        .then(page => {
            var script = /* 上面提到的 script */;
            var evaluation = page.evaluateJavaScript(script);
            evaluation.then(function (html) {
                html = JSON.parse(html);
                if (html.success) {
                    res.write('Check passed, flag: ' + /* 對應題目的 flag */);
                    res.end();
                } else {
                    res.write(html.error);
                    res.end();
                }
                if (!exitted) {
                    phInstance.exit();
                    exitted = true;
                }
            });
        })
        .catch(error => {
            console.log(error); // eslint-disable-line no-console
            if (!exitted) {
                phInstance.exit();
                exitted = true;
                res.write('PhantomJS error');
                res.end();
            }
        });
    // prevent time limit exceeded
    setTimeout(function () {
        if (!exitted) {
            phInstance.exit();
            exitted = true;
            res.write('TLE');
            res.end();
        }
    }, 5000);
}

最後配上一點樣式,就大功告成啦!

1

2

3


全部程式碼在 這裡

P.S. 即將到來的 NUAACTF 會使用這個程式來設計 XSS 題目。當然,題目與 Flag 均不是本文中放出來的這些。

P.S.S. Chrome 目前也推出了 Headless 模式,而且支援 Chrome 的全部特性。PhantomJS 作者表示自己要失業了……23333

Disqus 載入中……如未能載入,請將 disqus.com 和 disquscdn.com 加入白名單。

這是我們共同度過的

第 3848 天