题目描述:小明最近注册了很多网络平台账号,为了让账号使用不同的强密码,小明自己动手实现了一套非常"安全"的密码存储系统 -- 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 请求头:
AuthorizationX-UserX-Forwarded-ForCookie
最后是一个 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 请求头中的字段 Connection:Connection 里列出的字段名,表示这些头只对当前连接有效,代理在转发时应把它们去掉。
也就是说,当我们请求 127.0.0.1:5555 的时候,带上请求头:
......
......
Connection: X-User
反代看到该字段,则在转发给后端服务器的时候会将该字段从请求头中去除。
5000 API 接到请求后,发现并没有检测到 X-User 请求头字段,则默认将其设置为:
X-User: 0
绕过登入成功。
三、获取 Flag
尝试上述分析到的结果,改包:

从响应中可以看出直接重定向到 /dashboard 去了而不是 /sign,说明成功了。
开启 burp 的跟随重定向功能:

发送请求,得到结果:

成功得到 flag!