第一章:鄂尔多斯Gin框架安全加固的合规基线与威胁全景
鄂尔多斯政务云平台在构建微服务API网关时广泛采用Gin框架,其轻量与高性能优势显著,但默认配置存在多项安全缺口,亟需依据《GB/T 22239-2019 信息安全技术 网络安全等级保护基本要求》(等保2.0)第三级及《内蒙古自治区政务信息系统安全建设指南》开展定制化加固。
合规对齐核心基线
- 强制启用HTTPS并禁用HTTP明文监听(须配置TLS 1.2+,禁用SSLv3/TLS 1.0)
- 所有响应头注入
Content-Security-Policy: default-src 'self'、X-Content-Type-Options: nosniff、X-Frame-Options: DENY - 敏感路由(如
/admin/,/api/v1/users)必须通过RBAC中间件校验JWT声明中的scope与region字段,确保仅鄂尔多斯属地政务身份可访问
典型攻击面映射
| 威胁类型 | Gin默认风险点 | 鄂尔多斯场景影响 |
|---|---|---|
| HTTP Header注入 | c.Request.Header.Set()未过滤 |
可伪造X-Forwarded-For绕过IP白名单 |
| 模板引擎RCE | html/template误用用户输入 |
政务表单富文本渲染触发远程代码执行 |
| 日志敏感泄露 | c.String(200, "token=%s", token) |
日志落盘暴露OAuth2 Access Token |
关键加固操作步骤
启用严格CORS策略并绑定鄂尔多斯政务域名白名单:
// 在main.go初始化处添加
corsConfig := cors.Config{
AllowOrigins: []string{
"https://www.ordos.gov.cn",
"https://data.ordos.gov.cn",
"https://api.ordos.gov.cn", // 仅允许备案域名
},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Authorization", "Content-Type", "X-Requested-With"},
ExposeHeaders: []string{"X-Total-Count", "X-Request-ID"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}
r.Use(cors.New(corsConfig))
该配置拒绝非备案子域请求,且AllowCredentials: true仅在AllowOrigins为精确域名(非通配符)时生效,符合等保2.0“身份鉴别与访问控制”条款。
第二章:HTTP走私漏洞的深度防御体系构建
2.1 HTTP走私原理剖析与Gin中间件层协议解析机制
HTTP走私(HTTP Smuggling)源于客户端、代理与后端服务器对同一请求的消息边界解析不一致,常见于 Transfer-Encoding 与 Content-Length 并存时的歧义处理。
请求边界解析冲突示例
// Gin默认使用net/http标准库解析器,不主动校验TE/CL矛盾
func parseRequest(r *http.Request) {
// 标准库按RFC 7230优先级:TE > CL > body长度推断
// 若前端代理忽略TE而仅看CL,后端却信任TE,则分裂出隐藏请求
}
逻辑分析:
net/http在readRequest中依据transferEncoding字段决定是否启用分块解码;若请求含TE: chunked但代理误删该头而保留CL: 0,则代理认为请求结束,后端却继续读取后续“伪装”数据——形成走私通道。
Gin中间件协议解析关键点
- 默认不拦截或重写
Transfer-Encoding gin.Recovery()等中间件在路由前执行,无法干预底层解析- 自定义中间件需在
c.Request.Body被读取前介入校验
| 解析阶段 | Gin行为 | 风险点 |
|---|---|---|
| Header解析 | 透传标准库结果 | 不校验TE/CL一致性 |
| Body读取 | 延迟至c.ShouldBind()触发 |
此时走私已发生 |
| 中间件执行顺序 | 在http.Handler链中位于解析后 |
无法修复已错位边界 |
graph TD
A[Client] -->|含TE+CL歧义请求| B[Front Proxy]
B -->|仅依CL截断| C[Gin Server]
C -->|依TE持续读取| D[隐匿请求体]
2.2 基于Request/Response生命周期的走私检测实践(含Transfer-Encoding/Content-Length双头校验)
HTTP走私的核心在于前端(如CDN、负载均衡)与后端服务器对同一请求的解析不一致。关键突破口是 Transfer-Encoding(TE)与 Content-Length(CL)双头共存时的优先级分歧。
双头冲突触发条件
- 前端按 CL 解析,忽略 TE;后端按 TE(chunked)解析
- 或反之:前端信任 TE,后端回退至 CL
典型走私载荷示例
POST / HTTP/1.1
Host: example.com
Content-Length: 43
Transfer-Encoding: chunked
0
GET /admin HTTP/1.1
Host: example.com
Foo: x
逻辑分析:首行
表示 chunked 结束,后续GET /admin被后端视为新请求,而前端因 CL=43 已截断,将其“粘包”进下个请求。参数Content-Length: 43精确覆盖至x字符末尾,确保后端 chunked 解析边界准确。
检测验证流程
| 步骤 | 动作 | 预期响应特征 |
|---|---|---|
| 1 | 发送双头模糊请求 | 响应状态码异常(如 404→200跳变)或响应体出现跨请求内容 |
| 2 | 对比前后端日志时间戳与请求ID | 出现未记录的“幽灵请求”或请求计数不匹配 |
graph TD
A[客户端发送双头请求] --> B{前端解析策略}
B -->|优先CL| C[截断为43字节]
B -->|优先TE| D[解析chunked流]
C --> E[剩余数据→下一个请求]
D --> F[完整接收并路由]
E --> G[后端误判为独立请求→走私成功]
2.3 Gin自定义HTTP/1.1解析器替换方案与RFC 7230合规性验证
Gin 默认依赖 Go 标准库 net/http 的 HTTP/1.1 解析器,其行为严格遵循 RFC 7230,但无法动态干预请求头解析(如空格折叠、字段合并等边界场景)。
替换核心:http.Transport 与 http.Server 双层接管
需同时替换客户端出站与服务端入站解析路径,确保端到端语义一致。
自定义解析器关键钩子
// 实现 http.ResponseWriter 包装器,拦截并校验 Header 字段格式
type RFC7230ResponseWriter struct {
http.ResponseWriter
}
func (w *RFC7230ResponseWriter) WriteHeader(statusCode int) {
// 检查 statusCode 是否在 RFC 7230 定义的合法范围内(1xx–5xx)
if statusCode < 100 || statusCode > 599 || statusCode%100 == 0 {
panic("invalid status code per RFC 7230 §6.1")
}
w.ResponseWriter.WriteHeader(statusCode)
}
逻辑分析:该包装器在
WriteHeader阶段强制校验状态码结构,符合 RFC 7230 §6.1 对 status-line 的数值约束;statusCode%100 == 0排除如200(合法)但捕获2000(非法)等越界值。
RFC 7230 合规性检查项对照表
| 检查维度 | RFC 7230 章节 | Gin 默认行为 | 自定义补丁 |
|---|---|---|---|
| 头字段名大小写不敏感 | §3.2 | ✅ | ✅(保留) |
| 多值头自动合并 | §3.2.2 | ❌(保留原始切片) | ✅(按规范合并) |
| 空格折叠(LWS) | §3.2.3 | ⚠️(部分折叠) | ✅(全量标准化) |
graph TD
A[Client Request] --> B[net/http Server]
B --> C{RFC 7230 Parser?}
C -->|No| D[Gin Handler]
C -->|Yes| E[Custom LWS Normalizer]
E --> F[Header Canonicalization]
F --> D
2.4 利用net/http.Transport劫持与连接复用监控实现走私行为实时阻断
HTTP走私常利用Connection: keep-alive与后端服务器对Content-Length/Transfer-Encoding解析不一致发起攻击。net/http.Transport是关键拦截点。
连接层劫持入口
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
conn, err := (&net.Dialer{}).DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
// 包装为可观察连接
return &monitoredConn{Conn: conn}, nil
},
}
DialContext劫持原始连接建立,注入监控代理;monitoredConn可捕获TLS握手、首字节时间及后续读写流特征。
复用连接异常检测维度
| 指标 | 阈值 | 触发动作 |
|---|---|---|
| 同连接连续请求间隔 | 标记潜在走私会话 | |
Transfer-Encoding 与 Content-Length 共存 |
禁止 | 立即关闭连接 |
实时阻断流程
graph TD
A[新请求进入] --> B{是否复用已有连接?}
B -->|是| C[检查Header一致性与时间熵]
B -->|否| D[新建连接并注册监控器]
C --> E[异常?]
E -->|是| F[主动Reset TCP连接]
E -->|否| G[放行]
2.5 等保2.0三级要求下HTTP走私防护的审计日志格式与留存策略
等保2.0三级明确要求对Web层异常协议行为(如HTTP走私)进行全量、可追溯的日志记录,并保障日志留存不少于180天。
日志字段规范
必须包含:timestamp、client_ip、server_ip、request_id、http_method、host、content_length、transfer_encoding、smuggling_indicator(布尔值)、detection_rule_id、raw_request_hex(前128字节截断)。
示例结构化日志(JSON)
{
"timestamp": "2024-06-15T08:23:41.928Z",
"client_ip": "203.0.113.42",
"smuggling_indicator": true,
"detection_rule_id": "HTTP_SMUGGLING_CL_TE",
"raw_request_hex": "504f5354202f20485454502f312e310d0a436f6e74656e742d4c656e6774683a203532303030303030"
}
逻辑分析:
raw_request_hex采用十六进制编码,规避日志解析器误切分二进制payload;smuggling_indicator为WAF/网关在解析双编码头(如Content-Length与Transfer-Encoding共存)后触发的确定性标记;detection_rule_id需映射到GB/T 22239—2019附录F中对应检测项。
日志留存策略
| 项目 | 要求 |
|---|---|
| 存储周期 | ≥180天(本地+异地双备份) |
| 加密保护 | AES-256-GCM加密静态日志文件 |
| 完整性校验 | 每日生成SHA-256哈希链并上链存证 |
数据同步机制
graph TD
A[边缘WAF] -->|TLS加密Syslog| B(日志汇聚网关)
B --> C{规则引擎}
C -->|匹配CL/TE/TE/CL模式| D[标记smuggling_indicator=true]
C -->|标准化JSON| E[写入Elasticsearch+对象存储]
第三章:JWT鉴权绕过漏洞的全链路封堵
3.1 JWT签名失效、密钥泄露与算法混淆漏洞的Gin上下文注入路径分析
Gin中间件中的JWT解析盲区
当开发者使用 jwt-go v3.x 且未显式校验 SigningMethod 时,攻击者可篡改 alg: none 或切换为 HS256 伪造无签名令牌:
// 危险写法:未强制指定预期算法
token, err := jwt.Parse(claims, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil // 密钥硬编码+未校验alg
})
该代码未验证 token.Header["alg"],导致 none 算法绕过签名校验;密钥通过环境变量读取但未做运行时保护,易被内存dump或日志泄漏。
算法混淆触发条件
| 攻击向量 | Gin上下文影响点 | 可利用场景 |
|---|---|---|
alg: none |
c.Get("user") 返回伪造用户 |
权限越权 |
alg: HS256 + 公钥 |
c.Set("auth", token) 被污染 |
后续鉴权逻辑误信伪造载荷 |
上下文污染传播路径
graph TD
A[HTTP Header Authorization] --> B[Gin Handler: ParseToken]
B --> C{jwt-go Parse<br>未校验alg?}
C -->|Yes| D[接受none/RS256→HS256伪造]
D --> E[c.Set("user", claims)]
E --> F[下游业务逻辑信任c.MustGet]
- 密钥应使用
crypto/rand动态派生并绑定context.Context - 所有
Parse调用必须前置token.Method == jwt.SigningMethodHS256断言
3.2 基于Claims结构体动态校验与JWK Set远程轮询的强鉴权中间件实现
核心设计思想
将鉴权逻辑解耦为「声明解析」与「密钥供给」双通道:Claims 结构体承载可扩展校验策略(如 scope、tenant_id、exp),JWK Set 通过后台 goroutine 定期轮询更新,避免密钥硬编码与单点失效。
动态校验代码示例
func ValidateToken(jwtStr string, claims *CustomClaims) error {
keyFunc := func(t *jwt.Token) (interface{}, error) {
return jwkSet.GetKey(t.Header["kid"].(string)) // 依赖实时JWK缓存
}
return jwt.ParseWithClaims(jwtStr, claims, keyFunc)
}
CustomClaims继承jwt.MapClaims,支持嵌入业务字段;jwkSet.GetKey()从内存缓存中按kid查找公钥,毫秒级响应。
JWK 轮询机制对比
| 策略 | 频率 | 一致性保障 | 失效延迟 |
|---|---|---|---|
| 轮询(本方案) | 5m | 最终一致 | ≤5min |
| 启动加载 | 仅1次 | 弱 | 服务重启前持续生效 |
流程概览
graph TD
A[HTTP Request] --> B{JWT Token}
B --> C[Parse & Extract kid]
C --> D[JWK Cache Lookup]
D -->|Hit| E[Verify Signature]
D -->|Miss| F[Trigger Async Refetch]
F --> G[Update Cache]
E --> H[Validate Claims]
3.3 针对kid头部注入、none算法降级、RSA/ECDSA密钥混用的实战对抗代码
防御性JWT解析器核心逻辑
以下代码在验签前强制校验 alg 与 kid 的合法性,阻断三类攻击链:
def secure_jwt_verify(token: str, jwks: dict) -> bool:
header = jwt.get_unverified_header(token)
# 拦截 none 算法 & 非法 kid 格式(仅允许 UUID 或 base64url-safe 字符)
if header.get("alg") == "none" or not re.match(r'^[a-zA-Z0-9_-]{8,64}$', header.get("kid", "")):
raise InvalidTokenError("Invalid alg or malformed kid")
# 根据 kid 查找密钥,并严格匹配密钥类型与 alg(如 ES256 → EC key,RS256 → RSA key)
key = jwks.get(header["kid"])
if not key or (header["alg"].startswith("RS") and key["kty"] != "RSA") or \
(header["alg"].startswith("ES") and key["kty"] != "EC"):
raise InvalidTokenError("Key type mismatch with alg")
return jwt.decode(token, key=key["key"], algorithms=[header["alg"]])
逻辑分析:
- 第一层过滤:拒绝
"alg": "none"和kid含路径遍历/SQL注入特征(如../,' OR 1=1); - 第二层绑定:
kid查得的密钥kty必须与alg语义一致,杜绝 RSA私钥验签ECDSA签名的降级漏洞; - 参数说明:
jwks为预加载的可信密钥集(非动态远程获取),避免SSRF或缓存污染。
攻击向量对照表
| 攻击类型 | 触发条件 | 本方案拦截点 |
|---|---|---|
| kid注入 | kid: ../etc/passwd |
kid 正则校验失败 |
| none降级 | alg: none + 空签名 |
alg 显式黑名单 |
| RSA/ECDSA混用 | RS256签名 + EC公钥验证 | kty/alg 强类型校验 |
graph TD
A[收到JWT] --> B{解析header}
B --> C[alg == none?]
B --> D[无效kid格式?]
C -->|是| E[拒绝]
D -->|是| E
B --> F[查kid对应密钥]
F --> G{kty与alg匹配?}
G -->|否| E
G -->|是| H[执行标准验签]
第四章:内存泄漏与反射注入类高危风险治理
4.1 Gin Context生命周期管理缺陷导致goroutine泄漏的pprof定位与修复范式
pprof诊断关键路径
通过 go tool pprof http://localhost:8080/debug/pprof/goroutine?debug=2 获取阻塞型 goroutine 栈,重点关注 (*Context).Done、select 持有未关闭 channel 的调用链。
典型泄漏代码模式
func riskyHandler(c *gin.Context) {
done := c.Done() // 引用父Context,但c未返回即退出
go func() {
select {
case <-done: // 若c.Abort()或请求提前终止,done可能永不触发
log.Println("clean up")
}
}()
// 忘记c.Next()或c.Abort(),Context未被正常消费
}
逻辑分析:c.Done() 返回的 channel 依赖底层 http.Request.Context() 生命周期;若 handler 异常返回且未触发 c.Request.Cancel 或超时,goroutine 将永久挂起。done channel 在 c 被 GC 前不会关闭,而 c 又被闭包强引用。
修复范式对比
| 方案 | 安全性 | 适用场景 | 风险点 |
|---|---|---|---|
context.WithTimeout(c, 5*time.Second) |
✅ | 需主动控制子任务时限 | 需手动 defer cancel |
c.Request.Context() + select{case <-reqCtx.Done()} |
✅✅ | 标准 HTTP 生命周期对齐 | 依赖 client 正常断连 |
安全重构示例
func safeHandler(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel() // 确保资源释放
go func() {
select {
case <-ctx.Done():
log.Println("cleanup via request context")
}
}()
}
该写法将生命周期绑定至 http.Request.Context(),由 Go HTTP Server 在连接关闭/超时时自动 cancel,规避 Gin Context 管理盲区。
4.2 reflect.Value.Call与unsafe.Pointer误用引发的RCE链挖掘与沙箱化拦截方案
RCE链触发核心条件
当 reflect.Value.Call 被用于调用用户可控函数指针,且该指针由 unsafe.Pointer 强转自恶意字节码时,可绕过类型系统执行任意代码。
危险模式示例
func triggerRCE(payload []byte) {
fnPtr := (*[2]uintptr)(unsafe.Pointer(&payload[0]))[0] // ❌ 非法指针解引用
fnVal := reflect.ValueOf(unsafe.Pointer(uintptr(fnPtr))).Call(nil)
}
逻辑分析:
payload[0]无对齐校验,[2]uintptr强转导致越界读取;Call(nil)以空参数调用任意地址,触发 shellcode 执行。fnPtr应为uintptr类型,但此处未做有效性校验(如是否在.text段内)。
沙箱拦截关键点
| 拦截层 | 检查项 | 触发动作 |
|---|---|---|
| 反射调用前 | Value.Kind() == Func 且 CanInterface() |
拒绝调用 |
| unsafe 转换后 | uintptr 是否落在 mmap 分配的 RWX 内存页 |
清零并 panic |
graph TD
A[reflect.Value.Call] --> B{目标是否来自 unsafe.Pointer?}
B -->|是| C[检查目标地址段属性]
C -->|RWX| D[阻断+日志告警]
C -->|RO/.text| E[放行]
4.3 JSON Unmarshaler接口滥用导致的反序列化内存溢出防护(含CustomUnmarshaler白名单机制)
滥用场景:无限嵌套与超大数组触发OOM
当类型实现 json.Unmarshaler 且未校验输入长度时,攻击者可构造深度嵌套 JSON(如 {"a":{"a":{"a":...}}})或百MB级数组,使 UnmarshalJSON 在解析中持续分配内存直至耗尽。
CustomUnmarshaler 白名单机制
仅允许已注册的安全类型实现自定义反序列化:
var unmarshalerWhitelist = map[reflect.Type]bool{
reflect.TypeOf((*SafeConfig)(nil)).Elem(): true,
reflect.TypeOf((*UserSummary)(nil)).Elem(): true,
}
✅ 逻辑分析:运行时通过
reflect.TypeOf(v).Elem()获取指针所指类型;白名单在json.Unmarshal前拦截非授权UnmarshalJSON调用。参数v必须为指针,否则Elem()panic。
防护流程图
graph TD
A[收到JSON字节流] --> B{类型实现Unmarshaler?}
B -->|否| C[走默认反射解析]
B -->|是| D[查白名单]
D -->|不在白名单| E[返回ErrUnmarshalerForbidden]
D -->|在白名单| F[执行UnmarshalJSON]
| 风险类型 | 检测方式 | 默认动作 |
|---|---|---|
| 深度嵌套 > 100 | json.Decoder.DisallowUnknownFields() + 自定义 Scanner |
拒绝解析 |
| 数组元素 > 10k | json.RawMessage 预检长度 |
返回 ErrTooManyItems |
4.4 基于go:linkname与runtime/debug.ReadGCStats的内存敏感操作实时熔断策略
当服务面临突发内存压力时,需在GC指标异常升高的毫秒级窗口内拦截高开销操作。核心思路是绕过Go运行时封装,直接访问内部GC统计结构体。
直接读取GC元数据
//go:linkname gcstats runtime.gcstats
var gcstats struct {
nMallocs, nFrees uint64
pauseNs [256]uint64
pauseEnd [256]uint64
numPause uint32
}
// 调用前需确保 runtime.GC() 已触发至少一次,否则 pauseNs 可能全零
该go:linkname声明跳过导出检查,直接绑定未导出的runtime.gcstats变量;pauseNs数组记录最近256次GC停顿纳秒数,numPause为有效索引计数。
熔断判定逻辑
- 检查最近3次GC平均停顿 > 10ms
- 当前堆分配速率(
nMallocs - nFrees)环比上升超300% - 触发后拒绝
json.Marshal、template.Execute等内存敏感调用
| 指标 | 阈值 | 采样周期 |
|---|---|---|
pauseNs均值 |
>10ms | 最近3次 |
| 分配净增量增长率 | >300% | 1s窗口 |
graph TD
A[ReadGCStats] --> B{pauseNs均值>10ms?}
B -->|Yes| C{分配增速>300%?}
C -->|Yes| D[启用熔断]
C -->|No| E[维持正常]
B -->|No| E
第五章:鄂尔多斯政务云环境下的Gin安全加固落地成效与演进路线
安全加固实施背景与政务云约束条件
鄂尔多斯市大数据中心于2023年Q3启动“一网通办”后端服务重构,将12个核心业务模块(含不动产登记、社保资格核验、公积金提取)迁移至自治区级政务云平台(华为云Stack 8.2+等保三级认证环境)。该环境强制启用VPC网络隔离、统一WAF(华为云Web应用防火墙v5.4)、审计日志接入市级SOC平台,并禁止开放非80/443端口及外网直连数据库。原有Gin框架未启用中间件鉴权、日志明文输出敏感字段、未校验Content-Type导致CSRF绕过风险,在渗透测试中被识别为高危项。
关键加固措施与代码级落地实例
在main.go中注入统一安全中间件链:
r.Use(
security.CSPHeader(), // 注入Content-Security-Policy: default-src 'self'
security.SecureHeaders(), // 设置X-Content-Type-Options: nosniff等7项头
middleware.RateLimiter(100, time.Minute), // 基于IP+JWT的令牌桶限流
auth.JwtAuthMiddleware(jwtConf), // 集成市级统一身份认证中心RSA2048公钥验签
)
所有POST/PUT接口强制校验application/json或multipart/form-data,非法类型返回HTTP 406;响应体JSON序列化前自动脱敏身份证号(*替换第7–14位)、手机号(*替换第4–7位)。
实测成效量化对比
| 指标 | 加固前(2023-Q2) | 加固后(2024-Q1) | 提升幅度 |
|---|---|---|---|
| OWASP ZAP高危漏洞数 | 27 | 0 | 100% |
| 单日平均API攻击拦截量 | 1,240次(WAF日志) | 8,930次(含恶意User-Agent、SQLi载荷) | +620% |
| 等保2.0测评通过项 | 63/82 | 82/82 | +19项 |
| 敏感数据泄露事件 | 2起(日志文件误传OSS) | 0 | 100% |
运维协同机制建设
与政务云运维团队共建自动化巡检流水线:每日凌晨触发Jenkins任务,执行curl -I https://api.ordos.gov.cn/healthz验证HTTPS证书有效期、HSTS头存在性及CSP策略生效状态;失败时自动推送企业微信告警至安全组+开发负责人双通道。同时将Gin的gin.DefaultWriter重定向至云原生日志服务(LogService),按level=error自动触发SLS告警规则,关联调用链ID(OpenTelemetry注入)实现分钟级根因定位。
下一代演进路线图
- 零信任网关集成:2024年Q3对接市级SPIFFE基础设施,Gin服务间调用强制mTLS双向认证,废弃预共享Token机制;
- AI驱动的异常检测:接入政务云AI安全分析平台,基于LSTM模型对Gin访问日志中的URL路径、参数熵值、响应延迟进行实时聚类,动态生成阻断策略;
- WASM沙箱化路由:试点将高风险业务(如电子证照签章)编译为WASI兼容模块,在Gin的
http.HandlerFunc中通过Wazero运行时加载,实现内存级隔离。
合规性持续验证机制
每季度由内蒙古自治区信息安全测评中心执行红蓝对抗:蓝队提供Gin服务Docker镜像SHA256哈希值及K8s部署清单,红队在离线靶场复现环境,重点验证/debug/pprof端点禁用有效性、JWT密钥轮换后旧Token失效时效(≤5分钟)、以及自定义错误页是否泄露Gin版本号(已强制返回404而非默认404页面含Gin v1.9.1字样)。
