Comment trong HTML, <script> tag và một kỹ thuật CSP bypass để tấn công XSS

Le Quoc CuongLe Quoc Cuong
10 min read

Ngôn ngữ HTML được đặc trưng bởi các HTML tag và các thuộc tính (attributes) của mỗi tag. Trong bài viết này, chúng ta sẽ tìm hiểu về tag <script> trong HTML và một số hành vi mà mình cho là khá thú vị khi mà HTML parser và Javascript parser cùng hoạt động.

*Note: trong bài viết có một số chỗ mình gõ là “- - >”, bởi vì nếu viết liền thì hashnode parse text của mình thành “—>”, vì thế các bạn cứ hiểu ngầm là “- - >” không có dấu space nào hết nhé ^^

HTML standard

Ngôn ngữ HTML có một bộ quy chuẩn cho riêng mình, trong đó nó quy định về cú pháp của ngôn ngữ từ cơ bản đến phức tạp, cách sử dụng các HTML tag, attributes, cách mà dữ liệu văn bản trong các tag được xử lí, vân vân và mây mây [https://html.spec.whatwg.org]. HTML script tag cho phép developer nhúng các đoạn mã Javascript vào trang web trực tiếp mà không cần phải phân chia ra hẳn một static folder riêng biệt, từ đó việc lập trình trở nên thuận tiện hơn. Trong bộ quy chuẩn ở trên cũng có quy định đầy đủ về các mục liên quan đến tag script, trong đó có một section như sau: [https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements]

Vậy thì section này nó nói cái gì? Tổng quan, nó quy định về nội dung nên có bên trong một script element (mình dùng từ “nên” mặc dù tài liệu gốc có dùng từ “restrictions” là bởi vì ta có thể break những cái rule được nêu ở trong section này khá dễ dàng). Theo đó, nội dung văn bản bên trong một tag <script> thì nên có cấu trúc như sau:

        script = outer *( commment-open inner comment-close outer )

Để diễn giải cấu trúc trên, sau một thời gian nghiên cứu thì mình có đưa ra một số nhận định:

  • outer: là một string bất kì, miễn là nó không chứa substring nào match với not-in-outer (a.k.a comment-open)

  • comment-open: tức là “<!--”

  • inner: nội dung bên trong câu comment. Nó có thể là bất cứ thứ gì, miễn là không chứa substring nào match với not-in-inner (a.k.a comment-close hoặc script-open)

  • comment-close: tức là “- - >”

  • script-open: tức là open script tag, được diễn giải là một tag script có cấu trúc như sau:

    • “<“ s c r i p t tag-end

    • các kí tự s c r i p t có thể viết hoa thường tùy ý mà vẫn hợp lệ, chẳng hạn: <ScriPT> hay <SCRIPT> đều được chấp nhận.

    • tag-end là các kí tự được phép nằm ở vị trí kết thúc của tag, bao gồm các kí tự:

      • \t (U+0009, VD: <script[TAB]>

      • \n (U+000A, VD: <script[\n]>)

      • \f (U+000C, VD: <script[\f]>)

      • ‘ ‘ (dấu cách, VD: <script >)

      • / (U+002F, VD: <script/>)

      • cuối cùng là kí tự “>”

Khi mà trong script tag có chứa thêm cả script documentation (VD như comment) thì còn có thêm một vài rule được áp dụng (2).

Ta cần biết rằng, khi trình duyệt parse một file HTML, các HTML tag sẽ được parser parse vào DOM tree trước tiên, sau đó mới chuyển sang parse mã JavaScript bên trong <script> tag. Tuy nhiên, đối với comment, nếu như trong HTML comment có chứa 1 script tag “<script>” thì nó lại không bị comment đi, dẫn đến việc script block bị trình duyệt parse theo một cách kỳ lạ. Ví dụ như đoạn HTML sau:

<!DOCTYPE html>
<html>
    <head>
        <title>test</title>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            var x = "this is a test <!-- <script>";
            alert(x);
        </script>
    </body>
</html>

Expected behavior ở đây sẽ là string “this is a test <!-- <script>" được hàm alert() pop-up lên. Tuy nhiên khi chạy thử code trên bằng một trình duyệt bất kì (ở đây mình dùng Firefox) thì không có chuyện gì xảy ra cả. Thử quan sát xem mã HTML đã được parse như thế nào bằng cách view-source, ta nhận ra rằng toàn bộ phần phía sau “<!--” đều đã bị comment đi và trở thành 1 phần của script block:

Lý do là vì, bên trong 1 block script nếu có chứa “<!--” và “<script>” thì chúng phải được “balance” (tức là, “<!--” đi theo cặp với “- - >”, “<script>” cũng theo cặp với “</script>”), dựa vào sự balance đó thì HTML parser mới có thể xác định được là khi nào thì script block mới kết thúc.

Tất nhiên là, với block script như trên thì sẽ không có mã JS nào được thực thi cả, do chúng sai syntax. Tuy nhiên, với context là một trang web có 2 nơi cho phép chứa user input, và attacker thực hiện HTML injection như sau:

<!DOCTYPE html>
<html>
    <head>
        <title>test</title>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            var x = "this is a test <!-- <script>";
            alert(x);
        </script>
        <input type="text" value="end the test--></script><img src=x onerror=alert(1)>">
    </body>
</html>

Kết quả:

Khi view-source đoạn HTML trên, ta thấy được cách mà browser đã parse HTML:

Đoạn mã trên cũng gây ra alert() pop-up trên cả Chrome và Edge.

HTML injection to strict CSP bypass, leads to XSS attack

Một case study mà mình học được trong thời gian vừa qua chính là một challenge XSS thông qua bypass CSP ở giải CTF Cyberspace 2024. Source code ở (4) nếu các bạn muốn thử exploit.

Trang index cho ta thấy trang web này cho phép ta nhập các text snippet và tự review lại snippet đó:

Thử ấn view snippet, có thể thấy một vài kí tự đã được chuyển đổi:

Nếu ta click Display this snippet thì đoạn text bên trong textarea đã được parse vào DOM:

Ngoài ra còn có chức năng Report a bad snippet đến admin, ám chỉ rằng đây là một challenge XSS.

Thử đọc source code và phân tích challenge. Ở phần đầu của index.js ta thấy backend có sử dụng DOMPurify (check ở package.json thì đây là bản mới nhất), ngoài ra còn có một hàm translate dùng để chuyển đổi 1 số kí tự từ user input:

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const translate = text => {
    return text
        .replace( /J/gi, 'J' )
        .replace( /A/gi, 'Α' )
        .replace( /B/gi, 'Β' )
        .replace( /D/gi, 'Ⅾ' )
        .replace( /K/gi, 'К' )
        .replace( /H/gi, 'H' )
        .replace( /M/gi, 'М' )
        .replace( /O/gi, '0' )
        .replace( /\$/gi, '$' ) 
        .replace( /%/gi, '%' ) 
        .replace( /\//gi, '╱' )
        .replace( /\\/gi, '\' )
        .replace( /\{/gi, '{' )
        .replace( /\}/gi, '}' )
        .replace( /@/gi, '@' )
        .replace( /\[/gi, '[' )
        .replace( /\]/gi, ']' )
        .replace( /\^/gi, '^' )
        .replace( /_/gi, '_' )
}

Tại route /, backend nhận hai GET paramter là namesnippet. Riêng parameter snippet thì backend cẩn thận dùng DOMPurìy.sanitize() để loại bỏ đi các HTML tag và attribute nguy hiểm. Sau đó, snippet được đưa vào một Javascript code string và truyền thẳng vào template static/view.htm:

app.get('/', async (req, res) => {
    if ( !req.query.name || !req.query.snippet ) {
        return res.redirect( "/home.htm" );
    }

    const name = req.query.name;
    const snippet = DOMPurify.sanitize( req.query.snippet );
    const nonce = crypto.randomBytes( 16 ).toString('hex');
    const script =     `
        document.addEventListener( 'DOMContentLoaded', function () {
            document.getElementById( 'viewButton' ).addEventListener(
                'click',
                function () {
                    document.getElementById( 'viewSnippet' ).innerHTML = snippet;
                    document.getElementById( 'viewSnippet' ).id = 'snippetDisplay';
                }
            );
        } );
        var snippet = ${JSON.stringify(snippet)};
        // @license http://unlicense.org/UNLICENSE Unlicense`;

    const html = fillTemplate(
        fs.readFileSync( 'static/view.htm', 'utf-8' ),
        { nonce, script, name, snippet }
    );

    return res.
        set( {
            'Content-Security-Policy': `script-src 'nonce-${nonce}'`
        } ).send( html );
} );

Lưu ý rằng trong code trên, backend đã tạo ra một <script> block chứa thuộc tính nonce là một chuỗi hex ngẫu nhiên dài 32 kí tự, đi kèm với header Content-Security-Policy: script-src nonce-(hex string). Việc này khiến cho bất kì mã JS nào được tạo ra trong quá trình parse HTML DOM không có thuộc tính nonce match với chuỗi hex trên bị trình duyệt từ chối thực thi.

Quan sát static/view.htm, ta thấy file này được code “bẩn” một cách có chủ ý, điều hiếm khi thấy trong các challenge CTF:

<!doctype html><html><head><title>Snippet Viewer</title><link rel="stylesheet"
            href="style.css"><script
            nonce="${nonce}">${script}</script></head><body><main><h1>${name}</h1><textarea
                readonly rows=10>${snippet}</textarea><div
                id="viewSnippet"><button id="viewButton">Display this
                    snippet</button></div><aside><a href="/home.htm">Create a
                    new snippet</a> &middot; <a href="/report.htm">Report a bad
                    snippet</a></aside></main></body></html>

Ngoài ra challenge còn có route /report dùng để gửi XSS link đến admin bot lấy flag.

Dựa theo những gì mình quan sát được, ta thấy rằng parameter name không hề bị sanitize, vì vậy có thể inject HTML code tùy ý. Tuy nhiên, bởi vì CSP nên ta không thể sử dụng được các payload XSS thông thường:

Khi view-source thì ta thấy rằng string “test” và <img> tag mà ta inject vào đã được đưa vào HTML:

Nhận thấy rằng input của ta một phần rơi vào bên trong nội dung của tag <script> do server tạo ra, vậy thì nếu ta thử inject HTML comment vào thì sao? Ý tưởng là, ta sẽ mở comment tag ngay bên trong mã Javascript, thì phần còn lại của HTML cũng sẽ bị comment đi, sau đó chỉ việc đóng comment lại và thêm mã JS tùy ý vào. Tuy nhiên đời không như là mơ, DOMPurify cũng loại bỏ luôn string ”<!--”:

Tuy nhiên, theo hiểu biết cá nhân của mình thì DOMPurify không sanitize close comment tag “- - >”. Vậy thì thử đổi vị trí của 2 tag cho nhau xem:

Như vậy là đã inject comment thành công. Nhưng làm như vậy thì không thể khai thác được gì thêm.

Sau khi tìm hiểu cơ chế sanitize của DOMPurify thì nhận thấy rằng có khá nhiều HTML tag và attribute không bị DOMPurify loại bỏ (5, 6). Chẳng hạn, với snippet như sau thì hoàn toàn được chấp nhận:

Thử inject HTML comment như kiến thức ta đã biết ở đầu bài viết thì ta thu được một trang trắng hoàn toàn:

Như vậy, sau khi inject thì toàn bộ HTML phía sau đều đã bị HTML parser xem là 1 phần của script block => bước đầu khai thác thành công

Việc tiếp theo cần làm là inject thêm hàm alert() và đóng tag script sao cho script block là hợp lệ. Trước tiên, thử bỏ hàm alert() vào parameter name xem nó rơi vào chỗ nào:

Có một điều cần lưu ý ở đây, là ở đầu dòng đã có dấu //. Trong JS thì đây là single-line comment, vì thế nếu để hàm alert() rơi vào đây thì nó sẽ không thể được thực thi. Vì thế, ta biến nó thành multiline bằng cách thêm kí tự line feed “\n”:

Như vậy là xong, giờ công việc cuối cùng là đóng script block lại:

Có thể thấy, bằng việc lợi dụng hành vi của HTML parser mà ta có thể đưa code JS bất kì vào bên trong khối <script>, hoàn toàn bypass được CSP.

Bây giờ thì ta chỉ cần gửi payload chứa hàm fetch() đến admin bot để lấy flag:

Bài học kinh nghiệm

  1. HTML parser và JS parser có cách parse khác nhau hoàn toàn (tất nhiên), vì thế khi viết code HTML có chứa <script>, cần tuân thủ nghiên ngặt những gì được HTML standard định nghĩa, tránh cho 2 parser này hiểu nhầm nhau.

  2. Tránh không cho user input rơi vào bên trong script block. Nếu cần thiết, hãy dùng DOMPurify để sanitize user input, và áp dụng các biện pháp encode cần thiết. Ví dụ như ở bài trên, hoàn toàn có thể tránh được việc inject comment tag bằng cách encode kí tự “<“ thành \x3c.

  3. DOMPurify sẽ loại bỏ “<!--” và <script> tag từ user input, trừ khi hai string đó là thuộc tính của một allowed HTML tag. Chẳng hạn, <img id=”<!--<script>”>

  4. Đừng có code bẩn như challenge đã làm =))) Code sạch đẹp đi :3

Reference:

  1. https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements

  2. https://html.spec.whatwg.org/multipage/scripting.html#inline-documentation-for-external-scripts

  3. https://creds.nl/2024-07-27-overlooked-xss-vector

  4. https://drive.google.com/file/d/1ZFvw6FAmGVdOQDkp_DoMUaCFbEGwFnVa/view?usp=sharing

  5. https://github.com/cure53/DOMPurify/blob/1.0.8/src/tags.js

  6. https://github.com/cure53/DOMPurify/blob/1.0.8/src/attrs.js

0
Subscribe to my newsletter

Read articles from Le Quoc Cuong directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Le Quoc Cuong
Le Quoc Cuong