第九届强网杯 · SecretVault WP:HTTP Connection 头绕过 JWT 反代身份校验

题目描述:小明最近注册了很多网络平台账号,为了让账号使用不同的强密码,小明自己动手实现了一套非常"安全"的密码存储系统 -- SecretVault,但是健忘的小明没记住主密码,你能帮他找找吗?

一、yml 文件分析

题目给了源码,并且给了 docker-compose.yml 文件,我们可以直接在本地起环境。

在运行容器之前,先看看 .yml 文件中的内容:

复制代码
services:
  secretvault:
    build:
      context: .
    container_name: secretvault
    environment:
      ICQ_FLAG: flag{test}
    ports:
      - "5555:5555"

得到的信息:

  • 本地 flag 为:flag
  • 容器内部的 5555 端口映射到宿主机的 5555端口

二、登入界面分析

1、简单浏览

把容器跑起来:

bash 复制代码
# 在 docker-compose.yml 文件所在目录运行
docker compose up

由于端口仅映射了 5555 端口,那么直接浏览器访问:

复制代码
http://127.0.0.1:5555

看到的是一个登入界面,并且含注册功能:

CTRL + U 查看页面源码没有发现"注释信息泄露关键信息"的情况。

弱密码尝试登入 admin 账户无果,打算尝试注册一个 admin,发现:

用户已经存在,说明 admin 就很有可能是管理员账户,并且是我们最终的目标。

正常情况下,这里可以尝试做 SQL Fuzz 的操作,但是这题给了网站的源码,可以直接进行白盒审计。

2、反代

根据 5555 端口这个线索,我们可以定位到这段代码(main.go):

go 复制代码
log.Println("Authorizer middleware service is running at :5555")
	if err := http.ListenAndServe(":5555", authorizer); err != nil {
		log.Fatal(err)
	}

http.ListenAndServe 是 Go 标准库 net/http 里的一个函数,用来启动一个 HTTP 服务器,它有两个参数:

参数 类型 作用
addr string 指定监听的地址和端口
handler http.Handler 指定收到请求后由谁处理

因此,我们可以分析得到:服务器监听任意网卡的 5555 端口,并且由 authorizer 这个对象处理请求。

据此线索,去找 authorizer(还是在 main.go 文件中):

go 复制代码
authorizer := &httputil.ReverseProxy{Director: func(req *http.Request) {
		req.URL.Scheme = "http"
		req.URL.Host = "127.0.0.1:5000"

		uid := GetUIDFromRequest(req)
		log.Printf("Request UID: %s, URL: %s", uid, req.URL.String())
		req.Header.Del("Authorization")
		req.Header.Del("X-User")
		req.Header.Del("X-Forwarded-For")
		req.Header.Del("Cookie")

		if uid == "" {
			req.Header.Set("X-User", "anonymous")
		} else {
			req.Header.Set("X-User", uid)
		}
	}}

httputil.ReverseProxy 是 Go 中专门用来实现反向代理的结构体,用于处理:反代服务器和后端服务器之间的请求交互。

authorizer 实例化了这个结构体,并作为指向该实例地址的指针存在。

go 复制代码
req.URL.Scheme = "http"
req.URL.Host = "127.0.0.1:5000"

这代表真正的后端服务是开在 127.0.0.1:5000 上的。

指定了 127.0.0.1,说明不允许非本地网络访问。

接着看:

go 复制代码
uid := GetUIDFromRequest(req)

调用了 GetUIDFromRequest() 函数:

go 复制代码
func GetUIDFromRequest(r *http.Request) string {
	authHeader := r.Header.Get("Authorization")
	if authHeader == "" {
		cookie, err := r.Cookie("token")
		if err == nil {
			authHeader = "Bearer " + cookie.Value
		} else {
			return ""
		}
	}
	if len(authHeader) <= 7 || !strings.HasPrefix(authHeader, "Bearer ") {
		return ""
	}
	tokenString := strings.TrimSpace(authHeader[7:])
	if tokenString == "" {
		return ""
	}
	token, err := jwt.ParseWithClaims(tokenString, &AuthClaims{}, 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(SecretKey), nil
	})
	if err != nil {
		log.Printf("failed to parse token: %v", err)
		return ""
	}
	claims, ok := token.Claims.(*AuthClaims)
	if !ok || !token.Valid {
		log.Printf("invalid token claims")
		return ""
	}
	return claims.UID
}

该函数首先会查看 Http Header 中是否有 Authorization 字段,如果没有,则继续查看 Cookie 字段中的 token,如果有值则写入 authHeader 变量中,无值则直接函数返回空字符。

后续一连串的 if 都是用于判断这个 Token 是否是一个合法的 JWT Token(格式、长度等是否正确),并且判断签名是否正确。若合法且签名正确,则会接收 jwt.ParseWithClaims 函数的返回值。

这个函数可以在 Go 的官方文档(https://pkg.go.dev/github.com/golang-jwt/jwt/v5#section-readme)中看到:

其返回值:

复制代码
*Token, error

同意文档中可以找到:

go 复制代码
type Token struct {
	Raw       string         // Raw contains the raw token.  Populated when you [Parse] a token
	Method    SigningMethod  // Method is the signing method used or to be used
	Header    map[string]any // Header is the first segment of the token in decoded form
	Claims    Claims         // Claims is the second segment of the token in decoded form
	Signature []byte         // Signature is the third segment of the token in decoded form.  Populated when you [Parse] or sign a token
	Valid     bool           // Valid specifies if the token is valid.  Populated when you [Parse] a token
}

简单来说,token 会获得 JWT 的一些关键元素。

最后:

go 复制代码
claims, ok := token.Claims.(*AuthClaims)
......
return claims.UID

这回返回 payload 中的 UID 字段。

回到之前看到的:

go 复制代码
uid := GetUIDFromRequest(req)

现在我们知道,这一步会检查 jwt 的合法性和签名情况,如果正常,则返回 jwt payload 中的 uid 字段值并赋值给 uid 变量。

继续看:

go 复制代码
req.Header.Del("Authorization")
req.Header.Del("X-User")
req.Header.Del("X-Forwarded-For")
req.Header.Del("Cookie")

这说明反代传给后端服务器的 http 请求并非与用户原始请求相同,而是删减了一些 http 请求头:

  • Authorization
  • X-User
  • X-Forwarded-For
  • Cookie

最后是一个 if-else 判断:

go 复制代码
if uid == "" {
			req.Header.Set("X-User", "anonymous")
		} else {
			req.Header.Set("X-User", uid)
		}

如果 uid 没有值,则在请求头中加上:

复制代码
X-User: anonymous

如果 uid 有值,则在请求头中加上:

复制代码
X-User: <uid_value>

3、真正的后端

既然知道了 5555 是反代,当然要去找到真正的后端服务,也就是我们上面分析出来的 5000 端口,在 app.py 中就能看到监听信息:

python 复制代码
if __name__ == '__main__':
    flask_app = create_app()
    flask_app.run(host='127.0.0.1', port=5000, debug=False)

通过之前页面的简单浏览,我们知道,访问 127.0.0.1:5555 就会重定向到 127.0.0.1:5555/sign

对应的代码逻辑:

python 复制代码
@app.route('/')
def index():
	uid = request.headers.get('X-User', '0')
	if not uid or uid == 'anonymous':
		return redirect(url_for('login'))
	
	return redirect(url_for('dashboard'))

这里有个小发现,如果请求头中的 X-User 字段有值且不为"anonymous",则可以绕过登入,直接到 /dashboard 界面。

正常的请求包中是没有该字段的:

我们若手动添加该字段,则会被反代给删除(上面我们分析过的):

go 复制代码
req.Header.Del("X-User")

删除后,又会根据 uid 的值添加上了 X-User 字段并赋予新值:

go 复制代码
if uid == "" {
	req.Header.Set("X-User", "anonymous")
} else {
	req.Header.Set("X-User", uid)
}

而 uid 的值来自 jwt,若不知道签名密钥,则无法伪造正常 jwt。

换言之,我们无法在请求中伪造 X-User 字段来绕过登入判断。

但是这有个非常重要的细节,聚焦:

python 复制代码
request.headers.get('X-User', '0')

当请求头中没有 X-User 字段的时候,则会添加上:

复制代码
X-User: 0

而 0 刚好是能绕过登入判断的。

那么,我们就得想,如何让反代不传该请求头呢?

想到 http 请求头中的字段 ConnectionConnection 里列出的字段名,表示这些头只对当前连接有效,代理在转发时应把它们去掉。

也就是说,当我们请求 127.0.0.1:5555 的时候,带上请求头:

复制代码
......
......
Connection: X-User

反代看到该字段,则在转发给后端服务器的时候会将该字段从请求头中去除。

5000 API 接到请求后,发现并没有检测到 X-User 请求头字段,则默认将其设置为:

复制代码
X-User: 0

绕过登入成功。

三、获取 Flag

尝试上述分析到的结果,改包:

从响应中可以看出直接重定向到 /dashboard 去了而不是 /sign,说明成功了。

开启 burp 的跟随重定向功能:

发送请求,得到结果:

成功得到 flag!