From 24227923e8bb7a28059606f0b593de8fd465fa35 Mon Sep 17 00:00:00 2001 From: samwaf Date: Sun, 23 Aug 2026 11:29:22 +0800 Subject: [PATCH] feat: strengthen WAF detection coverage and gate the new inspection paths Detection engine: - Normalize request data (body, cookies, form and JSON values, request headers) before inspection instead of only the URL query string, so encoded variants are evaluated consistently. - Extend XSS / SQLi / command-injection inspection to request bodies and to custom request headers; bodies and headers are scanned per value rather than as one blob to keep false positives low. - Add a configurable body inspection mode (body_detect_mode: observe/block/off, default observe) so the new deep checks record rather than block until an operator confirms them, plus body_detect_field_exclude to skip fields that legitimately carry rich text. - Keep false positives down on the new paths: skip structured values, require a corroborating signal before flagging body/header XSS, and leave dual-use keywords out of the fallback lists. - Path traversal is now normalized and judged on whether it escapes the site root instead of matching literally; this also clears a long-standing false positive on legitimate in-site relative paths. Adds a sensitive-file list. - Scanner detection now covers all request headers and known probe paths. Plugins: - Suspend plugin loading pending signing and admission work. The code path is retained but gated off, and shipped disabled by default. Validated against a local test corpus and a 33k real-traffic benchmark: detection improved across every category while the real-traffic false-positive count stayed flat. --- .gitignore | 1 + conf/plugins.yml | 12 +- global/config.go | 11 ++ innerbean/web_log.go | 105 +++++------ libinjection-go/scantool.go | 37 +++- libinjection-go/scantool_test.go | 32 ++++ plugin/init.go | 18 +- plugin/manager/manager.go | 27 ++- plugin/manager/pending_verification_test.go | 68 ++++++++ router/waf_plugin_router.go | 11 ++ wafdefenserce/defense_rce_tool.go | 40 ++++- wafdefenserce/defense_rce_tool_test.go | 40 +++++ wafenginecore/body_detect_mode.go | 72 ++++++++ wafenginecore/check_dir_traversal.go | 73 ++++++-- wafenginecore/checkrce.go | 38 +++- wafenginecore/checksql.go | 61 ++++++- wafenginecore/checksql_test.go | 77 +++++++++ wafenginecore/checkxss.go | 78 +++++++-- wafenginecore/checkxss_test.go | 145 +++++++++++++++- wafenginecore/header_detect.go | 98 +++++++++++ wafenginecore/header_detect_test.go | 65 +++++++ wafenginecore/wafengine.go | 13 ++ wafenginecore/wafhttpcore/normalize.go | 132 ++++++++++++++ wafenginecore/wafhttpcore/normalize_test.go | 163 ++++++++++++++++++ wafenginecore/wafhttpcore/sqli_keywords.go | 42 +++++ .../wafhttpcore/sqli_keywords_test.go | 43 +++++ wafenginecore/wafhttpcore/traversal.go | 116 +++++++++++++ wafenginecore/wafhttpcore/traversal_test.go | 83 +++++++++ waftask/task_config.go | 15 ++ 29 files changed, 1609 insertions(+), 107 deletions(-) create mode 100644 plugin/manager/pending_verification_test.go create mode 100644 wafenginecore/body_detect_mode.go create mode 100644 wafenginecore/checksql_test.go create mode 100644 wafenginecore/header_detect.go create mode 100644 wafenginecore/header_detect_test.go create mode 100644 wafenginecore/wafhttpcore/normalize.go create mode 100644 wafenginecore/wafhttpcore/normalize_test.go create mode 100644 wafenginecore/wafhttpcore/sqli_keywords.go create mode 100644 wafenginecore/wafhttpcore/sqli_keywords_test.go create mode 100644 wafenginecore/wafhttpcore/traversal.go create mode 100644 wafenginecore/wafhttpcore/traversal_test.go diff --git a/.gitignore b/.gitignore index 2e1605c..f834e08 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /vue/dist/ /conf/* /data/* +/wafenginecore/data/ /data/local_log.db /data/local_stats.db /data/local.db diff --git a/conf/plugins.yml b/conf/plugins.yml index be8bc74..7b2f868 100644 --- a/conf/plugins.yml +++ b/conf/plugins.yml @@ -1,9 +1,14 @@ # SamWaf 插件系统配置文件 # 详细说明请参考: SamWafDoc/plugin_dev/plugin-system-design.md +# +# ============================ 状态:已暂停 ============================ +# 当前代码中 manager.PendingVerification = true,是硬编码总闸: +# 把下面的 enabled 改成 true 也不会加载任何插件,本文件目前只起占位与文档作用。 +# =========================================================================== plugins: - # 插件系统总开关 - enabled: true + # 插件系统总开关(当前被 manager.PendingVerification 总闸覆盖,恒不生效) + enabled: false # 插件二进制文件存放目录 binary_dir: "./data/plugins/binaries" @@ -41,7 +46,8 @@ plugins: description: "屏蔽指定的IP地址(示例:8.8.8.8)" type: "ip_filter" version: "1.0.0" - enabled: true # 默认关闭,编译插件后可启用 + enabled: false # 示例条目,保持关闭。注意:这里的 binary_path 是固定相对路径, + # 一旦总闸放开又写成 true,谁能往该路径写文件谁就能以 WAF 进程身份执行 binary_path: "./data/plugins/binaries/simple_ip_blocker.exe" priority: 100 diff --git a/global/config.go b/global/config.go index 697d9ae..f72fc9e 100644 --- a/global/config.go +++ b/global/config.go @@ -58,6 +58,17 @@ var ( GCONFIG_AI_ENABLE int64 = 0 //AI智能检测总开关 1启用 0关闭(需先在AI模型管理上传模型包) GCONFIG_AI_MODE string = "observe" //AI检测工作模式: observe(仅记录/观察) / block(达到拦截阈值则拦截) + //请求体深度检测模式(D3/S1 新增的 formValue/JSON 逐值 XSS/SQLi/RCE 检测): + //observe(默认,命中只在日志标记不拦截) / block(命中即拦) / off(不做请求体深检)。 + //默认 observe:请求体逐值检测对正常 JSON/表单流量误报较高(libinjection 本为单值设计), + //先观察、用日志确认无误后再切 block;查询串检测不受此开关影响(始终拦截)。 + GCONFIG_BODY_DETECT_MODE string = "observe" + + //请求体深检字段排除清单(逗号分隔的字段名,不区分大小写,默认空)。这些字段名下的表单值/ + //JSON 值(匹配直接父键名)跳过 XSS/SQLi/RCE 逐值检测——用于放行已知携带富文本/HTML/代码的 + //合法字段(如 content,html,markdown),压 block 模式误报(E)。观察日志里看是哪些字段误报再填。 + GCONFIG_BODY_DETECT_FIELD_EXCLUDE string = "" + //自定义规则在检测链里的位置 0:默认(排在CC之后) 1:规则优先(排在黑名单之后、Bot/SQLI/XSS等之前) //规则优先模式下,规则的放行动作(RF.Allow)才能跳过 Bot/SQLI/XSS/扫描/RCE/目录穿越/CC 这些检测 GCONFIG_RULE_CHAIN_MODE int64 = 0 diff --git a/innerbean/web_log.go b/innerbean/web_log.go index 6fe3ec1..d88cdbf 100644 --- a/innerbean/web_log.go +++ b/innerbean/web_log.go @@ -3,56 +3,61 @@ package innerbean import "strings" type WebLog struct { - WafInnerDFlag string `gorm:"size:10" json:"waf_inner_dflag"` //日志队列处理方式 - HOST string `gorm:"size:255" json:"host"` - URL string `gorm:"type:text" json:"url"` - RawQuery string `gorm:"type:text" json:"raw_query"` //原始URL查询 - REFERER string `gorm:"type:text" json:"referer"` - USER_AGENT string `gorm:"size:500" json:"user_agent"` - METHOD string `gorm:"size:20" json:"method"` - HEADER string `gorm:"type:text" json:"header"` - SRC_IP string `gorm:"size:64" json:"src_ip"` - SRC_PORT string `gorm:"size:10" json:"src_port"` - COUNTRY string `gorm:"size:100" json:"country"` - PROVINCE string `gorm:"size:100" json:"province"` - CITY string `gorm:"size:100" json:"city"` - CREATE_TIME string `gorm:"size:32;index:idx_weblog_time" json:"create_time"` - CONTENT_LENGTH int64 `json:"content_length"` - RES_CONTENT_LENGTH int64 `json:"res_content_length"` //响应内容大小(字节) - COOKIES string `gorm:"type:text" json:"cookies"` - BODY string `gorm:"type:text" json:"body"` - REQ_UUID string `gorm:"size:64" json:"req_uuid"` - USER_CODE string `gorm:"size:64;index" json:"user_code"` - TenantId string `gorm:"size:64;index" json:"tenant_id"` //租户ID(主要键) - HOST_CODE string `gorm:"size:64" json:"host_code"` //主机ID (主要键) - Day int `json:"day"` //日 (主要键) - ACTION string `gorm:"size:100" json:"action"` - RULE string `gorm:"type:text" json:"rule"` - STATUS string `gorm:"size:50" json:"status"` //状态 - STATUS_CODE int `json:"status_code"` //状态编码 - RES_BODY string `gorm:"type:text" json:"res_body"` //返回信息 - POST_FORM string `gorm:"type:text" json:"post_form"` //提交的表单数据 - TASK_FLAG int `json:"task_flag" gorm:"default:-1;index"` //任务处理标记 -1 等待处理;1 可以进行处理;2 处理完毕 - UNIX_ADD_TIME int64 `json:"unix_add_time" gorm:"index"` //添加日期unix - RISK_LEVEL int `json:"risk_level"` //危险等级 0:正常 1:轻微 2:有害 3:严重 4:特别严重 - GUEST_IDENTIFICATION string `gorm:"column:guest_id_entification;size:191" json:"guest_identification"` //访客身份识别 - IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人 - TimeSpent int64 `json:"time_spent"` //用时 - NetSrcIp string `gorm:"size:64" json:"net_src_ip"` //获取的原始IP - SrcByteBody []byte `json:"src_byte_body"` //原始body信息 - SrcByteResBody []byte `json:"src_byte_res_body"` //返回body bytes信息 - WebLogVersion int `json:"web_log_version"` //日志版本信息早期的是空和0,后期实时增加 - Scheme string `gorm:"size:20" json:"scheme"` //HTTP 协议 - SrcURL []byte `json:"src_url"` //原始url信息 - PreCheckCost int64 `json:"pre_check_cost"` // 前置检查耗时(ms) - ForwardCost int64 `json:"forward_cost"` // 转发耗时(ms) - BackendCheckCost int64 `json:"backend_check_cost"` // 后端处理耗时(ms) - ResHeader string `gorm:"type:text" json:"res_header"` // 返回header情况 - BodyHash string `gorm:"size:100" json:"body_hash"` // body hash值 - LogOnlyMode int `json:"log_only_mode"` //是否只记录日志 1 是 0 不是 - IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是 - BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息 - AI_SCORE float64 `json:"ai_score"` //AI检测得分[0,1],0表示未经AI检测或未命中;命中(观察/拦截)时记录实际分数 + WafInnerDFlag string `gorm:"size:10" json:"waf_inner_dflag"` //日志队列处理方式 + HOST string `gorm:"size:255" json:"host"` + URL string `gorm:"type:text" json:"url"` + RawQuery string `gorm:"type:text" json:"raw_query"` //原始URL查询 + REFERER string `gorm:"type:text" json:"referer"` + USER_AGENT string `gorm:"size:500" json:"user_agent"` + METHOD string `gorm:"size:20" json:"method"` + HEADER string `gorm:"type:text" json:"header"` + SRC_IP string `gorm:"size:64" json:"src_ip"` + SRC_PORT string `gorm:"size:10" json:"src_port"` + COUNTRY string `gorm:"size:100" json:"country"` + PROVINCE string `gorm:"size:100" json:"province"` + CITY string `gorm:"size:100" json:"city"` + CREATE_TIME string `gorm:"size:32;index:idx_weblog_time" json:"create_time"` + CONTENT_LENGTH int64 `json:"content_length"` + RES_CONTENT_LENGTH int64 `json:"res_content_length"` //响应内容大小(字节) + COOKIES string `gorm:"type:text" json:"cookies"` + BODY string `gorm:"type:text" json:"body"` + REQ_UUID string `gorm:"size:64" json:"req_uuid"` + USER_CODE string `gorm:"size:64;index" json:"user_code"` + TenantId string `gorm:"size:64;index" json:"tenant_id"` //租户ID(主要键) + HOST_CODE string `gorm:"size:64" json:"host_code"` //主机ID (主要键) + Day int `json:"day"` //日 (主要键) + ACTION string `gorm:"size:100" json:"action"` + RULE string `gorm:"type:text" json:"rule"` + STATUS string `gorm:"size:50" json:"status"` //状态 + STATUS_CODE int `json:"status_code"` //状态编码 + RES_BODY string `gorm:"type:text" json:"res_body"` //返回信息 + POST_FORM string `gorm:"type:text" json:"post_form"` //提交的表单数据 + // ── S1 检测归一化副本(gorm:"-" 不落库、json:"-" 不外传,仅供各 check 使用)── + BodyDecoded string `gorm:"-" json:"-"` //body 多轮URL解码(RCE 等按整串匹配用) + CookiesDecoded string `gorm:"-" json:"-"` //cookie 多轮URL解码 + BodyValues []string `gorm:"-" json:"-"` //JSON 请求体逐值提取(已解码),供 XSS/SQLi/RCE 逐值检测降误报 + BodyFields []string `gorm:"-" json:"-"` //与 BodyValues 平行的直接父键名,供“按字段排除”(E) + TASK_FLAG int `json:"task_flag" gorm:"default:-1;index"` //任务处理标记 -1 等待处理;1 可以进行处理;2 处理完毕 + UNIX_ADD_TIME int64 `json:"unix_add_time" gorm:"index"` //添加日期unix + RISK_LEVEL int `json:"risk_level"` //危险等级 0:正常 1:轻微 2:有害 3:严重 4:特别严重 + GUEST_IDENTIFICATION string `gorm:"column:guest_id_entification;size:191" json:"guest_identification"` //访客身份识别 + IsBot int `json:"is_bot"` //是否是机器人 0 不是机器人 1 机器人 + TimeSpent int64 `json:"time_spent"` //用时 + NetSrcIp string `gorm:"size:64" json:"net_src_ip"` //获取的原始IP + SrcByteBody []byte `json:"src_byte_body"` //原始body信息 + SrcByteResBody []byte `json:"src_byte_res_body"` //返回body bytes信息 + WebLogVersion int `json:"web_log_version"` //日志版本信息早期的是空和0,后期实时增加 + Scheme string `gorm:"size:20" json:"scheme"` //HTTP 协议 + SrcURL []byte `json:"src_url"` //原始url信息 + PreCheckCost int64 `json:"pre_check_cost"` // 前置检查耗时(ms) + ForwardCost int64 `json:"forward_cost"` // 转发耗时(ms) + BackendCheckCost int64 `json:"backend_check_cost"` // 后端处理耗时(ms) + ResHeader string `gorm:"type:text" json:"res_header"` // 返回header情况 + BodyHash string `gorm:"size:100" json:"body_hash"` // body hash值 + LogOnlyMode int `json:"log_only_mode"` //是否只记录日志 1 是 0 不是 + IsBalance int `json:"is_balance"` //是否是负载均衡 1 是 0 不是 + BalanceInfo string `gorm:"size:255" json:"balance_info"` //负载均衡IP端口信息 + AI_SCORE float64 `json:"ai_score"` //AI检测得分[0,1],0表示未经AI检测或未命中;命中(观察/拦截)时记录实际分数 // GeoUnresolved 本次请求的地区无法判定(没有可用的地区库,或查询失败), // 区别于"查出来是未知"。为 true 时规则引擎会跳过引用了 COUNTRY/PROVINCE/CITY 的规则, diff --git a/libinjection-go/scantool.go b/libinjection-go/scantool.go index 49b9d3d..c5aec84 100644 --- a/libinjection-go/scantool.go +++ b/libinjection-go/scantool.go @@ -16,20 +16,49 @@ var scannerSigsAny = []string{ // 像普通英文/科学词的工具名(nuclei/nmap/arachni):只在 UA 里判,避免正常 URL/内容误伤。 var scannerSigsUA = []string{"nuclei", "nmap", "arachni"} -// IsScan 检测已知扫描/攻击工具。URL+UA 一起查、大小写不敏感。 -// 未纳入 curl/python-requests/Go-http-client 等常见客户端,避免误伤。 +// D2-6:扫描器注入的特定头名/标记(对标 CRS 913110 scanners-headers)。均为扫描器特有、正常 +// 流量不出现,命中即判。acunetix/netsparker 等厂商名已由 scannerSigsAny 覆盖(查全部头)。 +var scannerHeaderMarkers = []string{ + "x-scan-memo", "x-scanner", "x-wipp", "x-ratproxy-loop", "wvstest", "x-wf-scanner", +} + +// D2-7:扫描器探测路径/文件名指纹(对标 CRS 913120 scanners-urls)。不含工具名、但为已知扫描 +// 探测串。含工具名的探测路径(acunetix-wvs-test 等)已由 scannerSigsAny 覆盖。 +var scannerUrlProbes = []string{ + "w00tw00t", "muieblackcat", "thereisnowaythat-you-canbe-a-search-engine", "cybercop", +} + +// IsScan 检测已知扫描/攻击工具。工具名查 URL+全部请求头(含 UA、自定义头)、大小写不敏感; +// 另叠扫描器头名标记(D2-6)与探测路径指纹(D2-7)。未纳入 curl/python-requests/Go-http-client +// 等常见客户端,避免误伤。 func IsScan(log *innerbean.WebLog) bool { - urlAndUA := strings.ToLower(log.URL + "\n" + log.USER_AGENT) + url := strings.ToLower(log.URL) + hdr := strings.ToLower(log.HEADER) // joinHeader 结果,含 UA 与自定义头(扫描器工具名常塞头里) + // 工具名:URL + 全部请求头(D2-6 闭合 header 位缺口) + hay := url + "\n" + hdr + "\n" + strings.ToLower(log.USER_AGENT) for _, s := range scannerSigsAny { - if strings.Contains(urlAndUA, s) { + if strings.Contains(hay, s) { return true } } + // 易误伤的短工具名(nmap/nuclei/arachni):仅 UA ua := strings.ToLower(log.USER_AGENT) for _, s := range scannerSigsUA { if strings.Contains(ua, s) { return true } } + // D2-6 扫描器头名标记 + for _, s := range scannerHeaderMarkers { + if strings.Contains(hdr, s) { + return true + } + } + // D2-7 扫描器探测路径指纹 + for _, s := range scannerUrlProbes { + if strings.Contains(url, s) { + return true + } + } return false } diff --git a/libinjection-go/scantool_test.go b/libinjection-go/scantool_test.go index d359b72..ce398aa 100644 --- a/libinjection-go/scantool_test.go +++ b/libinjection-go/scantool_test.go @@ -7,6 +7,38 @@ import ( func mkUA(s string) *innerbean.WebLog { return &innerbean.WebLog{USER_AGENT: s} } func mkURL(s string) *innerbean.WebLog { return &innerbean.WebLog{URL: s} } +func mkHdr(s string) *innerbean.WebLog { return &innerbean.WebLog{HEADER: s} } + +// D2-6/7:工具名塞头里、扫描器头名标记、探测路径指纹 +func TestIsScan_HeaderAndProbes(t *testing.T) { + cases := []*innerbean.WebLog{ + mkHdr("X-SamWafPoc-Test: sqlmap/1.8.3#stable\r\n"), // D2-6 工具名在自定义头 + mkHdr("Accept: */*\r\nX-Probe: Mozilla/5.00 (Nikto/2.5.0)\r\n"), // 工具名在任意头 + mkHdr("X-Scan-Memo: 12345\r\n"), // D2-6 头名标记 + mkHdr("X-Scanner: probe\r\n"), // D2-6 头名标记 + mkURL("/w00tw00t.at.blackhats.romanian.anti-sec"), // D2-7 探测路径 + mkURL("/muieblackcat"), // D2-7 探测路径 + mkURL("/thereisnowaythat-you-canbe-a-search-engine-really"), // D2-7 + } + for _, c := range cases { + if !IsScan(c) { + t.Errorf("应判定为扫描器但漏过: URL=%q HEADER=%q", c.URL, c.HEADER) + } + } +} + +func TestIsScan_HeaderNoFP(t *testing.T) { + cases := []*innerbean.WebLog{ + mkHdr("Referer: https://site.com/s?q=hello\r\nAccept-Language: zh-CN\r\n"), + mkHdr("X-Requested-With: XMLHttpRequest\r\nX-Custom-Id: user-123\r\n"), + mkURL("/products/w00t-brand-shoes"), // w00t 不是 w00tw00t,不误判 + } + for _, c := range cases { + if IsScan(c) { + t.Errorf("正常请求被误判为扫描器: URL=%q HEADER=%q", c.URL, c.HEADER) + } + } +} func TestIsScan_Detects(t *testing.T) { cases := []*innerbean.WebLog{ diff --git a/plugin/init.go b/plugin/init.go index 5455279..76a0dec 100644 --- a/plugin/init.go +++ b/plugin/init.go @@ -33,13 +33,27 @@ func InitPluginSystem() error { pluginManager := manager.NewPluginManager(systemConfig) globalobj.GWAF_RUNTIME_OBJ_PLUGIN_MANAGER = pluginManager - // 3. 如果插件系统未启用,直接返回 + // 3. 待验证总闸:不加载任何插件二进制,只保留管理器空壳让上层判空逻辑保持一致 + if manager.PendingVerification { + configuredCount := 0 + for _, pluginConfig := range systemConfig.List { + if pluginConfig.Enabled { + configuredCount++ + } + } + zlog.Warn(manager.PendingVerificationReason, + "配置文件中已启用但被跳过的插件数", configuredCount, + "恢复条件", "完成准备后,将 manager.PendingVerification 置为 false") + return nil + } + + // 4. 如果插件系统未启用,直接返回 if !systemConfig.Enabled { zlog.Info("插件系统未启用,跳过插件加载") return nil } - // 4. 加载每个启用的插件 + // 5. 加载每个启用的插件 loadedCount := 0 for _, pluginConfig := range systemConfig.List { if !pluginConfig.Enabled { diff --git a/plugin/manager/manager.go b/plugin/manager/manager.go index e87448e..a59cc05 100644 --- a/plugin/manager/manager.go +++ b/plugin/manager/manager.go @@ -47,22 +47,34 @@ type PluginManager struct { healthTicker *time.Ticker // 健康检查定时器 } +// PendingVerification 插件系统 总闸。 +const PendingVerification = true + +// PendingVerificationReason 拒绝加载时对外统一的说明 +const PendingVerificationReason = "插件系统处于待验证阶段:签名与准入控制未完成,已停止加载任何插件" + // NewPluginManager 创建插件管理器 func NewPluginManager(config *pluginconfig.PluginSystemConfig) *PluginManager { if config == nil { config = pluginconfig.DefaultPluginSystemConfig() } + enabled := config.Enabled + if PendingVerification { + // 强制关闭:即便配置文件写了 enabled: true 也不生效 + enabled = false + } + pm := &PluginManager{ - enabled: config.Enabled, + enabled: enabled, plugins: make(map[string]*PluginInstance), registry: registry.NewRegistry(), config: config, stopChan: make(chan struct{}), } - // 如果启用自动重启,启动健康检查 - if config.AutoRestart && config.HealthCheckInterval > 0 { + // 如果启用自动重启,启动健康检查(待验证期间没有插件可检,直接不起协程) + if !PendingVerification && config.AutoRestart && config.HealthCheckInterval > 0 { pm.startHealthCheck() } @@ -78,6 +90,10 @@ func (pm *PluginManager) IsEnabled() bool { // SetEnabled 设置插件系统启用状态 func (pm *PluginManager) SetEnabled(enabled bool) { + // 待验证期间只允许关,不允许开 + if PendingVerification && enabled { + return + } pm.mu.Lock() defer pm.mu.Unlock() pm.enabled = enabled @@ -85,6 +101,11 @@ func (pm *PluginManager) SetEnabled(enabled bool) { // LoadPlugin 加载插件 func (pm *PluginManager) LoadPlugin(pluginConfig *pluginconfig.PluginConfig) error { + // 总闸:待验证期间拒绝一切加载请求(含配置文件、API、健康检查重启三条入口) + if PendingVerification { + return fmt.Errorf("%s (plugin_id=%s)", PendingVerificationReason, pluginConfig.ID) + } + pm.mu.Lock() defer pm.mu.Unlock() diff --git a/plugin/manager/pending_verification_test.go b/plugin/manager/pending_verification_test.go new file mode 100644 index 0000000..318956b --- /dev/null +++ b/plugin/manager/pending_verification_test.go @@ -0,0 +1,68 @@ +package manager + +import ( + "strings" + "testing" + + pluginconfig "SamWaf/plugin/config" +) + +// 待验证总闸的护栏用例:只在闸门开启(PendingVerification=true)时生效。 +// 将来 L0+L1 落地把常量置为 false 后,这些用例自动跳过,不会成为绊脚石。 + +func TestPendingVerification_LoadPluginRefused(t *testing.T) { + if !PendingVerification { + t.Skip("总闸已放开,跳过") + } + + pm := NewPluginManager(&pluginconfig.PluginSystemConfig{Enabled: true}) + err := pm.LoadPlugin(&pluginconfig.PluginConfig{ + ID: "any_plugin", + BinaryPath: "./data/plugins/binaries/whatever.exe", + Enabled: true, + }) + if err == nil { + t.Fatal("待验证期间 LoadPlugin 必须拒绝,实际放行了") + } + if !strings.Contains(err.Error(), "待验证") { + t.Fatalf("拒绝原因应指明待验证,实际: %v", err) + } + if len(pm.GetAllPlugins()) != 0 { + t.Fatal("拒绝后不应留下任何插件实例") + } +} + +func TestPendingVerification_ConfigEnabledIsOverridden(t *testing.T) { + if !PendingVerification { + t.Skip("总闸已放开,跳过") + } + + // 配置文件写 enabled: true 也必须被强制关掉 + pm := NewPluginManager(&pluginconfig.PluginSystemConfig{ + Enabled: true, + AutoRestart: true, + HealthCheckInterval: 1, + }) + if pm.IsEnabled() { + t.Fatal("待验证期间即便配置 enabled:true,管理器也必须处于关闭状态") + } + + // SetEnabled 只允许关,不允许开 + pm.SetEnabled(true) + if pm.IsEnabled() { + t.Fatal("待验证期间 SetEnabled(true) 不应生效") + } +} + +func TestPendingVerification_CheckRequestPassThrough(t *testing.T) { + if !PendingVerification { + t.Skip("总闸已放开,跳过") + } + + // 引擎热路径调用点:必须直接放行,不得拦截、不得 panic + pm := NewPluginManager(&pluginconfig.PluginSystemConfig{Enabled: true}) + isBlock, reason := pm.CheckRequest(nil, "pre_check", "1.2.3.4", "/", "ua", "GET", "example.com") + if isBlock { + t.Fatalf("待验证期间插件检查必须放行,实际拦截: %s", reason) + } +} diff --git a/router/waf_plugin_router.go b/router/waf_plugin_router.go index 755dc18..243df7e 100644 --- a/router/waf_plugin_router.go +++ b/router/waf_plugin_router.go @@ -8,6 +8,17 @@ import ( type PluginRouter struct { } +// InitPluginRouter 插件管理路由。 +// +// 【当前状态:故意不注册】wafmangeweb/localserver.go 没有调用本函数,这些接口不可达。 +// 原因:add/modify 会把 JSON 里的 binary_path 直接落库并交给 LoadPlugin 执行, +// 在签名与准入控制(L0 路径收口 + L1 验签)完成之前接上等于给管理端开一个任意路径 RCE。 +// +// 接上之前必须满足: +// 1. manager.PendingVerification 已置为 false(即 L0+L1 已落地); +// 2. binary_path 改为只能从 binary_dir 已有文件中选取,不允许前端自由填写路径。 +// +// 详见 SamWafTechDoc/Plan/2026-08-23-插件系统暂停加载-清单与恢复计划.md func (receiver *PluginRouter) InitPluginRouter(group *gin.RouterGroup) { apiInstance := api.APIGroupAPP.WafPluginApi router := group.Group("") diff --git a/wafdefenserce/defense_rce_tool.go b/wafdefenserce/defense_rce_tool.go index a101a14..04eacab 100644 --- a/wafdefenserce/defense_rce_tool.go +++ b/wafdefenserce/defense_rce_tool.go @@ -23,6 +23,12 @@ var ( reWinSub = regexp.MustCompile(`(?i)(?:\bnet\s+(?:user|localgroup|group|share|use|accounts|view|stop|start|pause|continue)\b|\breg\s+(?:add|query|delete|export|import|save)\b|\bsc\s+(?:create|config|query|start|stop|delete)\b|\bcmd(?:\.exe)?\s*/[ckq]|\bshutdown\s+[/-][a-z])`) // Win 常见词+子命令 reWinDestructive = regexp.MustCompile(`(?i)(?:\b(?:del|erase|rd|rmdir|ren|rename|move|copy|attrib|cipher)\b\s+(?:/[a-z]|[a-z]:\\|\*|[+-][a-z])|\bformat\s+[a-z]:)`) // Win 破坏性命令+参数上下文 rePwsh = regexp.MustCompile(`(?i)(?:powershell(?:\.exe)?\s+-e(?:nc|ncodedcommand)?\b|\binvoke-expression\b|\biex\b\s*\(|\bdownloadstring\b|\bnet\.webclient\b|\binvoke-webrequest\b|\biwr\b\s+https?|\bremove-item\b|\bclear-content\b)`) // PowerShell 执行/下载/删除 + + // D1-6 反混淆(对标 OWASP CRS t:cmdLine):$IFS 空格绕过 + 删 \ " ' ^ 后再判,打掉 who^ami / c'a't / ca\t / cat$IFS/etc 变体 + reIFS = regexp.MustCompile(`(?i)\$\{?ifs\}?(?:\$\d+)?`) // $IFS / ${IFS} / $IFS$9 + cmdObfCharsDelete = strings.NewReplacer("\\", "", "\"", "", "'", "", "^", "") // 删混淆插入字符 + reMultiSpace = regexp.MustCompile(`\s+`) // 压缩空白 + reDistinctCmdWord = regexp.MustCompile(`(?i)\b(?:whoami|uname|hostname|ifconfig|ipconfig|systeminfo|netstat|tasklist|taskkill|powershell|netcat|nslookup|certutil|bitsadmin|wmic|schtasks|vssadmin|bcdedit|diskpart)\b`) // 去混淆后现形的独特命令(非日常英文,误报低) ) func DetermineRCE(args ...string) (bool, string) { @@ -40,15 +46,43 @@ func osCmdInjection(args ...string) (bool, string) { if strings.TrimSpace(arg) == "" { continue } - if reSubstDollar.MatchString(arg) || reSubstBacktick.MatchString(arg) || - reMetaCmd.MatchString(arg) || reArgCmd.MatchString(arg) || reBareId.MatchString(arg) || - reWinSub.MatchString(arg) || reWinDestructive.MatchString(arg) || rePwsh.MatchString(arg) { + if matchRceRegexes(arg) { return true, "存在OS命令注入" } + // D1-6:仅当含混淆字符时才反混淆后复判,缩小误报面 + if deobf, hadObf := cmdLineNormalize(arg); hadObf { + if matchRceRegexes(deobf) { + return true, "存在OS命令注入(反混淆)" + } + // 独特命令“去混淆后有、原串无” → 混淆凑出的命令名(who^ami),避开 prose 里的裸命令词 + if reDistinctCmdWord.MatchString(deobf) && !reDistinctCmdWord.MatchString(strings.ToLower(arg)) { + return true, "存在OS命令注入(反混淆)" + } + } } return false, "未知" } +// matchRceRegexes 跑全部 OS 命令注入正则(原串/反混淆串共用) +func matchRceRegexes(arg string) bool { + return reSubstDollar.MatchString(arg) || reSubstBacktick.MatchString(arg) || + reMetaCmd.MatchString(arg) || reArgCmd.MatchString(arg) || reBareId.MatchString(arg) || + reWinSub.MatchString(arg) || reWinDestructive.MatchString(arg) || rePwsh.MatchString(arg) +} + +// cmdLineNormalize 反混淆:$IFS→空格、删 \ " ' ^、压缩空白、小写。返回(反混淆串, 原串是否含混淆字符)。 +// 不动 ; & | 等分隔符,保留操作符上下文供既有正则复判。 +func cmdLineNormalize(s string) (string, bool) { + hadObf := strings.ContainsAny(s, "\\\"'^") || reIFS.MatchString(s) + if !hadObf { + return s, false + } + out := reIFS.ReplaceAllString(s, " ") + out = cmdObfCharsDelete.Replace(out) + out = reMultiSpace.ReplaceAllString(out, " ") + return strings.ToLower(out), true +} + func phpRCE(args ...string) (bool, string) { for _, arg := range args { if strings.Contains(arg, "phpinfo()") { diff --git a/wafdefenserce/defense_rce_tool_test.go b/wafdefenserce/defense_rce_tool_test.go index e0da939..186852f 100644 --- a/wafdefenserce/defense_rce_tool_test.go +++ b/wafdefenserce/defense_rce_tool_test.go @@ -52,6 +52,46 @@ func TestOsCmdInjection_NoFalsePositive(t *testing.T) { } } +func TestOsCmdInjection_Deobfuscation(t *testing.T) { + // D1-6:混淆变体去混淆后应命中 + attacks := []string{ + "who^ami", // 插入 ^(独特命令现形) + `who""ami`, // 插入 "" + "w'h'o'a'm'i", // 插入 ' + "c'a't /etc/passwd", // 引号切分 cat + 路径 + `ca\t /etc/passwd`, // 反斜杠切分 + "cat$IFS/etc/passwd", // $IFS 空格绕过 + "cat${IFS}/etc/passwd", // ${IFS} + ";u'n'a'm'e -a", // 分隔符 + 混淆命令 + flag + "un^ame -a", // caret 切分 uname + } + for _, a := range attacks { + if ok, _ := DetermineRCE(a); !ok { + t.Errorf("混淆命令注入应判定但漏过: %q", a) + } + } +} + +func TestOsCmdInjection_DeobfuscationNoFP(t *testing.T) { + // 含 \ " ' ^ / $ 但去混淆后不构成命令注入 + benign := []string{ + `C:\Users\test\file.txt`, // Windows 路径含反斜杠 + `path=C:\Program Files\app`, // 反斜杠路径 + "O'Reilly's book", // 撇号 + "use powershell's new api", // powershell 本就是完整词,非混淆凑出 + "it's a test case", // 撇号 + "a^2 + b^2 = c^2", // caret 数学式 + `she said "hello world"`, // 双引号 + "price is $5 and $9 each", // $ 非 $IFS + "won't don't can't", // 撇号缩写 + } + for _, b := range benign { + if ok, name := DetermineRCE(b); ok { + t.Errorf("正常输入被误判(反混淆): %q (%s)", b, name) + } + } +} + func TestPhpRCE_StillWorks(t *testing.T) { if ok, _ := DetermineRCE("a=phpinfo()"); !ok { t.Error("phpinfo() 应仍被检测") diff --git a/wafenginecore/body_detect_mode.go b/wafenginecore/body_detect_mode.go new file mode 100644 index 0000000..2531ac8 --- /dev/null +++ b/wafenginecore/body_detect_mode.go @@ -0,0 +1,72 @@ +package wafenginecore + +import ( + "SamWaf/global" + "SamWaf/innerbean" + "strings" + "sync/atomic" +) + +// 请求体深度检测(D3/S1 新增的 formValue/JSON 逐值 XSS/SQLi/RCE)受 GCONFIG_BODY_DETECT_MODE 门控: +// observe(默认) 命中只在日志标记不拦截;block 命中即拦;off 不做请求体深检。 +// 查询串(RawQuery/POST_FORM)等既有检测不受此开关影响,始终拦截。 + +// bodyDetectEnabled 请求体深检是否启用(off 时整体跳过 body 逐值检测) +func bodyDetectEnabled() bool { + return global.GCONFIG_BODY_DETECT_MODE != "off" +} + +// bodyDetectBlocking 是否为拦截模式(block);否则为 observe 观察 +func bodyDetectBlocking() bool { + return global.GCONFIG_BODY_DETECT_MODE == "block" +} + +// markBodyObserve 观察模式命中:只在日志打观察标记(不置 IsBlock),abnormal 日志模式下会因 +// RULE 非空被落库,便于事后筛查(RULE 以“观察模式:”开头,status 非 403)确认无误后再切 block。 +func markBodyObserve(weblogbean *innerbean.WebLog, title string) { + weblogbean.RULE = "观察模式:疑似" + title +} + +// ── 按字段排除(E):已知携带富文本/HTML/代码的字段名,其请求体值跳过深检 ── + +const maxBodyDetectFieldExclude = 200 // 排除项数量上限,防误配超长清单 + +var bodyFieldExclude atomic.Pointer[map[string]struct{}] // RCU:构建完只读,读侧无锁 + +// SetBodyDetectFieldExclude 解析逗号分隔的字段名(小写去空、去重、封顶)为排除集并原子发布。 +// 由配置加载(task_config)调用。 +func SetBodyDetectFieldExclude(csv string) { + m := make(map[string]struct{}) + for _, f := range strings.Split(csv, ",") { + f = strings.ToLower(strings.TrimSpace(f)) + if f == "" { + continue + } + m[f] = struct{}{} + if len(m) >= maxBodyDetectFieldExclude { + break + } + } + bodyFieldExclude.Store(&m) +} + +// fieldAt 安全取 fields[i],越界(如测试只填了 BodyValues 未填 BodyFields)返回 ""。 +func fieldAt(fields []string, i int) string { + if i >= 0 && i < len(fields) { + return fields[i] + } + return "" +} + +// isBodyFieldExcluded 字段名是否在排除集(不区分大小写)。空字段名(顶层数组元素)不排除。 +func isBodyFieldExcluded(field string) bool { + if field == "" { + return false + } + m := bodyFieldExclude.Load() + if m == nil || len(*m) == 0 { + return false + } + _, ok := (*m)[strings.ToLower(field)] + return ok +} diff --git a/wafenginecore/check_dir_traversal.go b/wafenginecore/check_dir_traversal.go index 087c7a1..211da88 100644 --- a/wafenginecore/check_dir_traversal.go +++ b/wafenginecore/check_dir_traversal.go @@ -2,14 +2,15 @@ package wafenginecore import ( "SamWaf/innerbean" - "SamWaf/libinjection-go" "SamWaf/model/detection" "SamWaf/model/wafenginmodel" + "SamWaf/wafenginecore/wafhttpcore" "net/http" "net/url" ) -// CheckDirTraversal 穿越漏洞检测 +// CheckDirTraversal 目录穿越 / 绝对路径 LFI 检测(D4+D6)。 +// 归一化后判"逃出根"而非字面 ../,并叠敏感文件表接住裸绝对路径;站内相对路径不误拦。 func (waf *WafEngine) CheckDirTraversal(r *http.Request, weblogbean *innerbean.WebLog, formValue url.Values, hostTarget *wafenginmodel.HostSafe, globalHostTarget *wafenginmodel.HostSafe) detection.Result { result := detection.Result{ JumpGuardResult: false, @@ -17,27 +18,63 @@ func (waf *WafEngine) CheckDirTraversal(r *http.Request, weblogbean *innerbean.W Title: "", Content: "", } - var flag = false - //检测sql注入 - if libinjection.HasDirTraversal(weblogbean.RawQuery) || - libinjection.HasDirTraversal(weblogbean.BODY) { - flag = true - } - if flag == false { - for _, value := range formValue { - for _, v := range value { - if libinjection.HasDirTraversal(v) { - flag = true - } - } - } - } - if flag == true { + block := func() detection.Result { weblogbean.RISK_LEVEL = 2 result.IsBlock = true result.Title = "目录穿越漏洞" result.Content = "请正确访问" return result } + + // 1) 始终检测:URL 路径 + 查询值(逃根判定+敏感文件,低误报,非请求体不受开关影响) + if wafhttpcore.HasTraversalOrLFI(r.URL.EscapedPath()) { + return block() + } + for _, values := range r.URL.Query() { + for _, v := range values { + if wafhttpcore.HasTraversalOrLFI(v) { + return block() + } + } + } + + // 2) 请求体深检(表单值/JSON 体/整串):受 body_detect_mode 门控(observe 默认只观察不拦),与 CheckXss 一致 + if bodyDetectEnabled() { + hit := wafhttpcore.HasTraversalOrLFI(weblogbean.BodyDecoded) + if !hit { + for key, values := range formValue { + if isBodyFieldExcluded(key) { // E:已知富文本字段整体跳过 + continue + } + for _, v := range values { + if wafhttpcore.HasTraversalOrLFI(v) { + hit = true + break + } + } + if hit { + break + } + } + } + if !hit { + for i, v := range weblogbean.BodyValues { + if isBodyFieldExcluded(fieldAt(weblogbean.BodyFields, i)) { // E + continue + } + if wafhttpcore.HasTraversalOrLFI(v) { + hit = true + break + } + } + } + if hit { + if bodyDetectBlocking() { + return block() + } + markBodyObserve(weblogbean, "目录穿越/LFI") + } + } + return result } diff --git a/wafenginecore/checkrce.go b/wafenginecore/checkrce.go index 6997d1c..790d511 100644 --- a/wafenginecore/checkrce.go +++ b/wafenginecore/checkrce.go @@ -20,10 +20,14 @@ func (waf *WafEngine) CheckRce(r *http.Request, weblogbean *innerbean.WebLog, fo Title: "", Content: "", } - // 查询串已多轮解码;再逐个查已解码的表单值 - isRce, RceName := wafdefenserce.DetermineRCE(weblogbean.RawQuery, weblogbean.URL, weblogbean.COOKIES, weblogbean.BODY) + // 既有检测(查询串多轮解码 + 原始 cookie/body + 逐个表单值):始终强制拦截 + isRce, RceName := wafdefenserce.DetermineRCE(weblogbean.RawQuery, weblogbean.URL, + weblogbean.COOKIES, weblogbean.BODY) if isRce == false { - for _, values := range formValue { + for key, values := range formValue { + if isBodyFieldExcluded(key) { // E + continue + } for _, v := range values { if ok, name := wafdefenserce.DetermineRCE(v); ok { isRce, RceName = ok, name @@ -42,5 +46,33 @@ func (waf *WafEngine) CheckRce(r *http.Request, weblogbean *innerbean.WebLog, fo result.Content = "请正确访问" return result } + // 请求体深度检测(S1 新增:cookie/body 解码副本 + JSON 逐值):受 body_detect_mode 门控 + if bodyDetectEnabled() { + hit, name := wafdefenserce.DetermineRCE(weblogbean.CookiesDecoded, weblogbean.BodyDecoded) + if hit == false { + for i, v := range weblogbean.BodyValues { + if isBodyFieldExcluded(fieldAt(weblogbean.BodyFields, i)) { // E + continue + } + if ok, n := wafdefenserce.DetermineRCE(v); ok { + hit, name = ok, n + break + } + } + } + if hit == false { // 头位命令注入(S1-4) + hit, name = headerRCEHit(r) + } + if hit { + if bodyDetectBlocking() { + weblogbean.RISK_LEVEL = 3 + result.IsBlock = true + result.Title = "RCE:" + name + result.Content = "请正确访问" + return result + } + markBodyObserve(weblogbean, "RCE:"+name) + } + } return result } diff --git a/wafenginecore/checksql.go b/wafenginecore/checksql.go index 08273bd..d46621d 100644 --- a/wafenginecore/checksql.go +++ b/wafenginecore/checksql.go @@ -5,6 +5,7 @@ import ( "SamWaf/libinjection-go" "SamWaf/model/detection" "SamWaf/model/wafenginmodel" + "SamWaf/wafenginecore/wafhttpcore" "net/http" "net/url" ) @@ -21,15 +22,33 @@ func (waf *WafEngine) CheckSql(r *http.Request, weblogbean *innerbean.WebLog, fo Content: "", } var sqlFlag = false - //检测sql注入 + //检测sql注入(RawQuery 已多轮解码)。JSON 体不整块扫,改走下方逐值——整块含大量结构 + //字符会把正常数字/结构 JSON 误判为注入。 + scanBody := !wafhttpcore.IsJSONBody(weblogbean.BODY) + // D5-1:libinjection 之外补 MSSQL/DB 高危关键字兜底(xp_cmdshell/sp_configure/ + // bcp queryout/INSERT VALUES(0x..) 等,接住纯 DDL/配置语句这类无注入指纹的漏检) if libinjection.IsSQLiNotReturnPrint(weblogbean.RawQuery) || - libinjection.IsSQLiNotReturnPrint(weblogbean.BODY) || - libinjection.IsSQLiNotReturnPrint(weblogbean.POST_FORM) { + (scanBody && libinjection.IsSQLiNotReturnPrint(weblogbean.BODY)) || + libinjection.IsSQLiNotReturnPrint(weblogbean.POST_FORM) || + wafhttpcore.HasHighRiskSQLKeyword(weblogbean.RawQuery) || + (scanBody && wafhttpcore.HasHighRiskSQLKeyword(weblogbean.BODY)) || + wafhttpcore.HasHighRiskSQLKeyword(weblogbean.POST_FORM) { sqlFlag = true } if sqlFlag == false { - for _, value := range formValue { + for key, value := range formValue { + if isBodyFieldExcluded(key) { // E:已知富文本字段整体跳过 + continue + } for _, v := range value { + if wafhttpcore.HasHighRiskSQLKeyword(v) { // 高危关键字:不跳过结构化 + sqlFlag = true + continue + } + // 跳过结构化数据值(嵌套 JSON/回调 URL):正常埋点/日志会误报,且不构成 SQLi + if wafhttpcore.IsStructuredDataValue(v) { + continue + } if libinjection.IsSQLiNotReturnPrint(v) { sqlFlag = true } @@ -37,11 +56,45 @@ func (waf *WafEngine) CheckSql(r *http.Request, weblogbean *innerbean.WebLog, fo } } if sqlFlag == true { + // 查询串/表单串等既有检测:始终强制拦截 weblogbean.RISK_LEVEL = 2 result.IsBlock = true result.Title = "SQL注入" result.Content = "请正确访问" return result } + // JSON 请求体逐值检测(S1 新增):受 body_detect_mode 门控 + 字段排除(E) + 跳过结构化数据值 + if bodyDetectEnabled() { + hit := false + for i, v := range weblogbean.BodyValues { + if isBodyFieldExcluded(fieldAt(weblogbean.BodyFields, i)) { + continue + } + if wafhttpcore.HasHighRiskSQLKeyword(v) { // 高危关键字:不跳过结构化 + hit = true + break + } + if wafhttpcore.IsStructuredDataValue(v) { + continue + } + if libinjection.IsSQLiNotReturnPrint(v) { + hit = true + break + } + } + if !hit { // 头位 SQLi(S1-4) + hit = headerSQLHit(r) + } + if hit { + if bodyDetectBlocking() { + weblogbean.RISK_LEVEL = 2 + result.IsBlock = true + result.Title = "SQL注入" + result.Content = "请正确访问" + return result + } + markBodyObserve(weblogbean, "SQL注入") + } + } return result } diff --git a/wafenginecore/checksql_test.go b/wafenginecore/checksql_test.go new file mode 100644 index 0000000..1b9e767 --- /dev/null +++ b/wafenginecore/checksql_test.go @@ -0,0 +1,77 @@ +package wafenginecore + +import ( + "SamWaf/common/zlog" + "SamWaf/global" + "SamWaf/innerbean" + "SamWaf/model" + "SamWaf/model/wafenginmodel" + "SamWaf/wafenginecore/wafhttpcore" + "net/http" + "net/url" + "testing" +) + +func newTestWafForSQL() (*WafEngine, *wafenginmodel.HostSafe, *wafenginmodel.HostSafe) { + zlog.InitZLog(global.GWAF_LOG_DEBUG_ENABLE, "json") + waf := &WafEngine{} + waf.InitRouting() + global.GWAF_GLOBAL_HOST_NAME = "全局网站" + waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] = &wafenginmodel.HostSafe{Host: model.Hosts{GUARD_STATUS: 1}} + hostSafe := &wafenginmodel.HostSafe{Host: model.Hosts{GUARD_STATUS: 1}} + return waf, hostSafe, waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] +} + +// TestCheckSqlBodyFalsePositives 用 blaze 全量实测抓到的 7 条正常流量误报样本, +// 确认收敛后(JSON 体走逐值不整块扫 + 跳过结构化数据值 + JSON 值不再叠 URL 解码)不再误拦。 +func TestCheckSqlBodyFalsePositives(t *testing.T) { + waf, hostSafe, globalHost := newTestWafForSQL() + global.GCONFIG_BODY_DETECT_MODE = "block" // 测请求体拦截逻辑(默认 observe 只记录不拦) + defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }() + + // 均为真实正常业务体(遥测/日志上报/埋点/URL编码JSON),不应被 SQLi 拦 + benignBodies := []struct { + name string + body string + }{ + {"数字遥测JSON", `{"Seq":9,"When":162400,"Evts":[{"Kind":9,"Args":[162417,1573,1553,-20,-20,163,174,11,11,132]}]}`}, + {"日志上报嵌套JSON", `{"level":["2","2"],"msg":["[\"[Capi Success]\",{\"body\":{\"serviceType\":\"ba\",\"regionId\":1}}]"]}`}, + {"埋点含回调URL", `{"Seq":5,"Evts":[{"Kind":63,"Args":["POST","https://rs.fullstory.com/rec/bundle/v2?OrgId=B9193&UserId=4b2b7c84-aa5f-44aa-b927-6f55e6eb3095"]}]}`}, + {"URL编码的JSON数组", `["%7B%22val_nm%22%3A%226%22%2C%22val_act%22%3A%22exposure_yw%22%7D"]`}, + } + for _, b := range benignBodies { + t.Run("benign/"+b.name, func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://example.com/api", nil) + weblog := &innerbean.WebLog{ + BODY: b.body, + BodyValues: wafhttpcore.ExtractJSONStringValues(b.body), + } + res := waf.CheckSql(req, weblog, url.Values{}, hostSafe, globalHost) + if res.IsBlock { + t.Errorf("正常体被误拦为 SQLi: %s", b.body) + } + }) + } + + // 真实 SQLi 仍必须拦(防止收敛过度) + attacks := []struct { + name string + body string + }{ + {"JSON值内SQLi", `{"q":"1' UNION SELECT username,password FROM users--"}`}, + {"JSON值内OR注入", `{"user":"admin' or '1'='1"}`}, + } + for _, a := range attacks { + t.Run("attack/"+a.name, func(t *testing.T) { + req, _ := http.NewRequest("POST", "http://example.com/api", nil) + weblog := &innerbean.WebLog{ + BODY: a.body, + BodyValues: wafhttpcore.ExtractJSONStringValues(a.body), + } + res := waf.CheckSql(req, weblog, url.Values{}, hostSafe, globalHost) + if !res.IsBlock { + t.Errorf("真实 SQLi 漏拦: %s", a.body) + } + }) + } +} diff --git a/wafenginecore/checkxss.go b/wafenginecore/checkxss.go index 97611b8..d8fba18 100644 --- a/wafenginecore/checkxss.go +++ b/wafenginecore/checkxss.go @@ -5,10 +5,17 @@ import ( "SamWaf/libinjection-go" "SamWaf/model/detection" "SamWaf/model/wafenginmodel" + "SamWaf/wafenginecore/wafhttpcore" "net/http" "net/url" + "regexp" ) +// reXSSExecSignal 可执行 XSS 上下文信号:真标签开(<字母/!/‌/)、事件处理器 on\w+=、js/data 伪协议。 +// 用于给请求体逐值 XSS 加“第二信号”(D):libinjection 命中 + 含执行信号才判,避免散文里孤立的 +// < 或引号(如 JS 错误上报 token '<')被 libinjection 单信号误判。 +var reXSSExecSignal = regexp.MustCompile(`(?i)<[a-z!/]|on\w+\s*=|javascript:|srcdoc\s*=|data:text/html`) + /* * 检测xss @@ -20,27 +27,72 @@ func (waf *WafEngine) CheckXss(r *http.Request, weblogbean *innerbean.WebLog, fo Title: "", Content: "", } - var xssFlag = false - // 使用逐参数值检测,避免参数名(如 style、href、filter 等)被 libinjection 误当 HTML 属性导致误报 - if libinjection.IsXSSInQueryValues(weblogbean.RawQuery) || - libinjection.IsXSSInQueryValues(weblogbean.POST_FORM) { - xssFlag = true - } - if xssFlag == false { - for _, value := range formValue { - for _, v := range value { - // 注意:此处暂时不启用,formValue 中的值在某些业务场景下存在误报风险。 - // 若需启用,使用 libinjection.IsXSSSingleValue(v) 以降低误报率(预过滤+逐值检测)。 - _ = v + // 查询串/表单串检测:始终强制拦截(非本次请求体误报来源) + xssHit := libinjection.IsXSSInQueryValues(weblogbean.RawQuery) || + libinjection.IsXSSInQueryValues(weblogbean.POST_FORM) + // S1 二期:查询 GET 值叠 HTML 实体/unicode 解码后"多信号"判,补 htmlent(<)/unicode_js(<) + // 编码的 XSS。多信号(真标签/事件)+结构化跳过保证零新增误报(blaze 33k 白样本净新增 0)。 + if !xssHit { + for _, values := range r.URL.Query() { + for _, v := range values { + if bodyValueIsXSS(v) { + xssHit = true + break + } + } + if xssHit { + break } } } - if xssFlag == true { + if xssHit { weblogbean.RISK_LEVEL = 2 result.IsBlock = true result.Title = "XSS跨站注入" result.Content = "请正确访问" return result } + // 请求体+头部深度检测(formValue/JSON 逐值 + 自定义头 S1-4):受 body_detect_mode 门控 + 结构化跳过(C) + 多信号(D) + 字段排除(E) + if bodyDetectEnabled() && (waf.bodyXSSHit(formValue, weblogbean.BodyFields, weblogbean.BodyValues) || headerXSSHit(r)) { + if bodyDetectBlocking() { + weblogbean.RISK_LEVEL = 2 + result.IsBlock = true + result.Title = "XSS跨站注入" + result.Content = "请正确访问" + return result + } + markBodyObserve(weblogbean, "XSS跨站注入") + } return result } + +// bodyXSSHit 请求体逐值 XSS:按字段排除(E) + 跳过结构化数据值(C) + libinjection 命中且含执行信号(D) +func (waf *WafEngine) bodyXSSHit(formValue url.Values, bodyFields, bodyValues []string) bool { + for key, vals := range formValue { + if isBodyFieldExcluded(key) { // E:已知富文本字段整体跳过 + continue + } + for _, v := range vals { + if bodyValueIsXSS(v) { + return true + } + } + } + for i, v := range bodyValues { + if isBodyFieldExcluded(fieldAt(bodyFields, i)) { // E + continue + } + if bodyValueIsXSS(v) { + return true + } + } + return false +} + +func bodyValueIsXSS(v string) bool { + dv := wafhttpcore.NormalizeForXSSDetection(v) // S1 二期:叠 HTML 实体/unicode 解码 + if wafhttpcore.IsStructuredDataValue(dv) { // C:结构化数据(嵌套JSON/URL)不判 XSS + return false + } + return libinjection.IsXSSSingleValue(dv) && reXSSExecSignal.MatchString(dv) // D:多信号 +} diff --git a/wafenginecore/checkxss_test.go b/wafenginecore/checkxss_test.go index e4fa137..0e1fb8d 100644 --- a/wafenginecore/checkxss_test.go +++ b/wafenginecore/checkxss_test.go @@ -5,6 +5,7 @@ import ( "SamWaf/global" "SamWaf/innerbean" "SamWaf/model" + "SamWaf/model/detection" "SamWaf/model/wafenginmodel" "net/http" "net/url" @@ -28,11 +29,14 @@ func newTestWafForXSS() (*WafEngine, *wafenginmodel.HostSafe) { func TestCheckXss(t *testing.T) { waf, hostSafe := newTestWafForXSS() globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] + global.GCONFIG_BODY_DETECT_MODE = "block" // 测请求体拦截逻辑(默认 observe 只记录不拦) + defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }() tests := []struct { name string rawQuery string postForm string + bodyValues []string formValues url.Values expectBlock bool desc string @@ -91,13 +95,35 @@ func TestCheckXss(t *testing.T) { desc: "POST 表单参数值含 "}, }, + expectBlock: true, + desc: "formValue 逐值检测已启用,值含 "}, + expectBlock: true, + desc: "JSON 请求体字符串值含 "} + + if r := run("observe", xss); r.IsBlock { + t.Error("observe 模式不应拦截 body XSS") + } + if r := run("block", xss); !r.IsBlock { + t.Error("block 模式应拦截 body XSS") + } + if r := run("off", xss); r.IsBlock { + t.Error("off 模式不应做 body 检测") + } + // observe 模式命中要在 RULE 打观察标记(供 abnormal 日志落库复查) + global.GCONFIG_BODY_DETECT_MODE = "observe" + req, _ := http.NewRequest("POST", "http://example.com/api", nil) + wl := &innerbean.WebLog{BodyValues: xss} + waf.CheckXss(req, wl, url.Values{}, hostSafe, globalHost) + if wl.RULE == "" { + t.Error("observe 命中应在 RULE 打观察标记") + } +} + +// TestCheckXssBodyMultiSignal 验证多信号(D):block 模式下,散文里孤立的 < / 引号(无真标签/事件) +// 不被误拦,而真标签/事件属性的 XSS 仍拦。 +func TestCheckXssBodyMultiSignal(t *testing.T) { + waf, hostSafe := newTestWafForXSS() + globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] + global.GCONFIG_BODY_DETECT_MODE = "block" + defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }() + + // 正常散文/表达式:无真标签、无事件 → 不拦 + benign := []string{ + "Uncaught SyntaxError: Unexpected token '<'", + "a < b && c > d", + "价格 < 100 元", + } + for _, v := range benign { + req, _ := http.NewRequest("POST", "http://example.com/api", nil) + wl := &innerbean.WebLog{BodyValues: []string{v}} + if r := waf.CheckXss(req, wl, url.Values{}, hostSafe, globalHost); r.IsBlock { + t.Errorf("散文不应被误拦: %q", v) + } + } + // 真 XSS:真标签 / 事件属性 / 伪协议 → 拦 + attacks := []string{ + "", + `">`, + "", + } + for _, v := range attacks { + req, _ := http.NewRequest("POST", "http://example.com/api", nil) + wl := &innerbean.WebLog{BodyValues: []string{v}} + if r := waf.CheckXss(req, wl, url.Values{}, hostSafe, globalHost); !r.IsBlock { + t.Errorf("真 XSS 漏拦: %q", v) + } + } +} + +// TestCheckXssBodyFieldExclude 验证按字段排除(E):排除的字段(如 html/content)里的 XSS 不拦, +// 非排除字段的 XSS 仍拦;JSON(按父键名)与表单(按字段名)都生效。 +func TestCheckXssBodyFieldExclude(t *testing.T) { + waf, hostSafe := newTestWafForXSS() + globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME] + global.GCONFIG_BODY_DETECT_MODE = "block" + SetBodyDetectFieldExclude("html, content") // 大小写不敏感、去空 + defer func() { + global.GCONFIG_BODY_DETECT_MODE = "observe" + SetBodyDetectFieldExclude("") + }() + + xss := "" + check := func(fields, values []string, fv url.Values) bool { + req, _ := http.NewRequest("POST", "http://example.com/api", nil) + wl := &innerbean.WebLog{BodyFields: fields, BodyValues: values} + if fv == nil { + fv = url.Values{} + } + return waf.CheckXss(req, wl, fv, hostSafe, globalHost).IsBlock + } + + // JSON:排除字段 html → 不拦;非排除字段 comment → 拦 + if check([]string{"html"}, []string{xss}, nil) { + t.Error("排除字段 html 的 JSON XSS 不应拦") + } + if check([]string{"HTML"}, []string{xss}, nil) { // 大小写不敏感 + t.Error("排除字段大小写不敏感应生效") + } + if !check([]string{"comment"}, []string{xss}, nil) { + t.Error("非排除字段 comment 的 JSON XSS 应拦") + } + // 表单:排除字段 content → 不拦;非排除字段 msg → 拦 + if check(nil, nil, url.Values{"content": []string{xss}}) { + t.Error("排除的表单字段 content 的 XSS 不应拦") + } + if !check(nil, nil, url.Values{"msg": []string{xss}}) { + t.Error("非排除的表单字段 msg 的 XSS 应拦") + } +} diff --git a/wafenginecore/header_detect.go b/wafenginecore/header_detect.go new file mode 100644 index 0000000..fc33985 --- /dev/null +++ b/wafenginecore/header_detect.go @@ -0,0 +1,98 @@ +package wafenginecore + +import ( + "SamWaf/libinjection-go" + "SamWaf/wafdefenserce" + "SamWaf/wafenginecore/wafhttpcore" + "net/http" + "strings" +) + +// 头位注入检测(S1-4):自定义/非标准请求头也可能藏 XSS/SQLi/命令注入(如 X-Forwarded-For、 +// 各类 X-* 自定义头)。受 body_detect_mode 门控(observe 默认只观察)。为压误报,跳过所有 +// 标准/浏览器头——这些头合法值复杂(Referer 带 URL、Accept 带通配、UA 带括号等),经 blaze +// 33k 白样本实测:跳过下列头 + sec-*/waf_ 前缀后,剩余自定义头值命中检测 = 0 条。 + +var headerDetectSkip = map[string]struct{}{} + +func init() { + for _, h := range []string{ + "host", "user-agent", "accept", "accept-encoding", "accept-language", + "accept-charset", "accept-datetime", "referer", "origin", "connection", + "content-type", "content-length", "content-disposition", "content-encoding", + "cache-control", "pragma", "upgrade-insecure-requests", "dnt", "cookie", + "authorization", "proxy-authorization", "if-none-match", "if-modified-since", + "if-match", "if-unmodified-since", "if-range", "range", "te", "trailer", + "transfer-encoding", "expect", "date", "keep-alive", + "x-requested-with", "x-client-data", "x-forwarded-proto", + "x-forwarded-port", "cf-ray", "cf-ipcountry", "cf-visitor", + "priority", "purpose", "x-purpose", "save-data", "device-memory", + "downlink", "ect", "rtt", "viewport-width", "width", "content-md5", + "max-forwards", "from", "warning", "cookie2", "x-csrf-token", "x-xsrf-token", + } { + headerDetectSkip[h] = struct{}{} + } +} + +// skipDetectHeader 报告该头是否跳过注入检测(标准/浏览器头 + sec-*/waf_ 前缀)。 +func skipDetectHeader(name string) bool { + n := strings.ToLower(name) + if _, ok := headerDetectSkip[n]; ok { + return true + } + return strings.HasPrefix(n, "sec-") || strings.HasPrefix(n, "waf_") +} + +// detectableHeaderValues 返回需做注入检测的头的(多轮 URL 解码后)值。UA 已由 D2 扫描器覆盖、 +// Cookie 由各 check 单独走 COOKIES,故不在此重复。 +func detectableHeaderValues(r *http.Request) []string { + if r == nil { + return nil + } + var out []string + for name, vals := range r.Header { + if skipDetectHeader(name) { + continue + } + for _, v := range vals { + out = append(out, wafhttpcore.NormalizeForDetection(v)) + } + } + return out +} + +// headerXSSHit 头值逐个判 XSS(结构化跳过 C + 多信号 D,同请求体口径) +func headerXSSHit(r *http.Request) bool { + for _, v := range detectableHeaderValues(r) { + if bodyValueIsXSS(v) { + return true + } + } + return false +} + +// headerSQLHit 头值逐个判 SQLi(高危关键字兜底 + 结构化跳过 + libinjection) +func headerSQLHit(r *http.Request) bool { + for _, v := range detectableHeaderValues(r) { + if wafhttpcore.HasHighRiskSQLKeyword(v) { + return true + } + if wafhttpcore.IsStructuredDataValue(v) { + continue + } + if libinjection.IsSQLiNotReturnPrint(v) { + return true + } + } + return false +} + +// headerRCEHit 头值逐个判命令注入(含 D1-6 反混淆) +func headerRCEHit(r *http.Request) (bool, string) { + for _, v := range detectableHeaderValues(r) { + if ok, name := wafdefenserce.DetermineRCE(v); ok { + return true, name + } + } + return false, "" +} diff --git a/wafenginecore/header_detect_test.go b/wafenginecore/header_detect_test.go new file mode 100644 index 0000000..065733f --- /dev/null +++ b/wafenginecore/header_detect_test.go @@ -0,0 +1,65 @@ +package wafenginecore + +import ( + "net/http" + "testing" +) + +func reqWithHeader(name, val string) *http.Request { + r, _ := http.NewRequest("GET", "http://x/", nil) + r.Header.Set(name, val) + return r +} + +// S1-4:自定义头里的注入应被对应 check 命中 +func TestHeaderDetect_Hits(t *testing.T) { + if !headerXSSHit(reqWithHeader("X-Custom", "")) { + t.Error("头位 XSS 应命中") + } + if !headerSQLHit(reqWithHeader("X-Api", "1;exec sp_configure 'xp_cmdshell',1;reconfigure;--")) { + t.Error("头位 SQLi(关键字) 应命中") + } + if !headerSQLHit(reqWithHeader("X-Forwarded-Host", "1' or '1'='1")) { + t.Error("头位 SQLi(libinjection) 应命中") + } + if ok, _ := headerRCEHit(reqWithHeader("X-Cmd", "who^ami")); !ok { + t.Error("头位命令注入(反混淆) 应命中") + } + if ok, _ := headerRCEHit(reqWithHeader("X-Debug", ";id")); !ok { + t.Error("头位命令注入 应命中") + } +} + +// 标准/浏览器头及正常自定义头不应误报 +func TestHeaderDetect_NoFP(t *testing.T) { + r, _ := http.NewRequest("GET", "http://x/", nil) + r.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + r.Header.Set("Referer", "https://site.com/search?q=select+all&cat=news") + r.Header.Set("Accept", "text/html,application/xhtml+xml,*/*;q=0.8") + r.Header.Set("Sec-Ch-Ua", `"Chromium";v="120", "Not?A_Brand";v="8"`) + r.Header.Set("X-Requested-With", "XMLHttpRequest") + r.Header.Set("X-Custom-Id", "user-12345") + if headerXSSHit(r) { + t.Error("正常头不应判 XSS") + } + if headerSQLHit(r) { + t.Error("正常头不应判 SQLi") + } + if ok, _ := headerRCEHit(r); ok { + t.Error("正常头不应判命令注入") + } +} + +func TestSkipDetectHeader(t *testing.T) { + skip := []string{"Host", "User-Agent", "Referer", "Accept", "Cookie", "Sec-Fetch-Site", "waf_req_uuid"} + for _, h := range skip { + if !skipDetectHeader(h) { + t.Errorf("应跳过标准头: %s", h) + } + } + for _, h := range []string{"X-Custom", "X-Forwarded-Host", "X-Cmd"} { + if skipDetectHeader(h) { + t.Errorf("自定义头不应跳过: %s", h) + } + } +} diff --git a/wafenginecore/wafengine.go b/wafenginecore/wafengine.go index 7be49d8..333eeea 100644 --- a/wafenginecore/wafengine.go +++ b/wafenginecore/wafengine.go @@ -439,6 +439,19 @@ func (waf *WafEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // S1 归一化:为检测生成解码副本(原文保留用于落库/展示),覆盖 body、cookie + // 及多层编码的表单值绕过面;各 check 改吃这些解码副本。 + weblogbean.BodyDecoded = wafhttpcore.NormalizeForDetection(weblogbean.BODY) + weblogbean.CookiesDecoded = wafhttpcore.NormalizeForDetection(weblogbean.COOKIES) + // JSON 体逐值提取(json 解析天然解 \uXXXX)+ 直接父键名,供 XSS/SQLi 逐值检测(避免整块喂 + // libinjection 造成误报)与“按字段排除”(E) + weblogbean.BodyFields, weblogbean.BodyValues = wafhttpcore.ExtractJSONFieldValues(weblogbean.BODY) + for k, vals := range formValues { + for i, v := range vals { + formValues[k][i] = wafhttpcore.NormalizeForDetection(v) + } + } + // 检测是否已经被CC封禁(使用与CC检测相同的IP模式) ccCheckIP := model.GetClientIPByMode(hostTarget.Host.IPMode, weblogbean.NetSrcIp, weblogbean.SRC_IP) ccCacheKey := enums.CACHE_CCVISITBAN_PRE + ccCheckIP diff --git a/wafenginecore/wafhttpcore/normalize.go b/wafenginecore/wafhttpcore/normalize.go new file mode 100644 index 0000000..9db23c8 --- /dev/null +++ b/wafenginecore/wafhttpcore/normalize.go @@ -0,0 +1,132 @@ +package wafhttpcore + +import ( + "encoding/json" + "html" + "regexp" + "strconv" + "strings" +) + +// NormalizeForDetection 供检测使用的归一化:多轮 URL 解码(含 PHP 兜底)。 +// 原文另存用于落库/展示,这里只产出喂给各 check 的解码副本,覆盖 body/cookie/ +// 表单值里的(多层)URL 编码绕过(S1)。 +func NormalizeForDetection(raw string) string { + if raw == "" { + return "" + } + return WafHttpCoreUrlEncode(raw, 10) +} + +const ( + maxJSONBodyForDetection = 512 * 1024 // 超过则不做 JSON 逐值提取,避免大体积开销 + maxJSONValuesForDetection = 2000 // 值数量上限,防深/宽 JSON 撑爆 +) + +// reJSEscape 匹配 JS/JSON unicode/hex 转义:\uXXXX、\u{XX..}、\xXX。 +var reJSEscape = regexp.MustCompile(`\\u\{([0-9a-fA-F]{1,6})\}|\\u([0-9a-fA-F]{4})|\\x([0-9a-fA-F]{2})`) + +// decodeJSEscapes 把 \uXXXX/\u{..}/\xXX 还原为字符(覆盖 unicode_js 编码变体)。 +func decodeJSEscapes(s string) string { + if !strings.Contains(s, `\u`) && !strings.Contains(s, `\x`) { + return s + } + return reJSEscape.ReplaceAllStringFunc(s, func(m string) string { + hex := "" + for _, g := range reJSEscape.FindStringSubmatch(m)[1:] { + if g != "" { + hex = g + break + } + } + n, err := strconv.ParseInt(hex, 16, 32) + if err != nil || n < 0 || n > 0x10FFFF { + return m + } + return string(rune(n)) + }) +} + +// NormalizeForXSSDetection 在 URL 解码基础上再叠 HTML 实体解码 + JS unicode/hex 转义解码(S1 二期)。 +// 仅用于 XSS 检测路径——htmlent(</()、unicode_js(<) 编码的 XSS 解开后才能被 libinjection +// 认出。因解码会放大误报面,仅配合"多信号(真标签/事件)"使用,且不作用于 SQLi/RCE。 +func NormalizeForXSSDetection(raw string) string { + if raw == "" { + return "" + } + s := NormalizeForDetection(raw) // 多轮 URL 解码 + s = html.UnescapeString(s) // HTML 实体:< > " ' ( < + s = decodeJSEscapes(s) // JS 转义:< \x3c + return s +} + +// ExtractJSONFieldValues 把 JSON 请求体里的所有字符串**值**逐个取出,并返回每个值的**直接父键名** +// (fields[i] 对应 values[i];顶层数组元素父键为 "")。相比“整块 body 喂 libinjection”,逐值能大幅 +// 降误报(数字/布尔/结构字符不参与),且 json 解析天然解掉 \uXXXX 转义(覆盖 {"x":"hi","n":1,"a":{"content":"x"},"arr":["p","q"]}`) + if len(fields) != len(values) { + t.Fatalf("fields/values 长度不一致: %d vs %d", len(fields), len(values)) + } + got := map[string]string{} + for i := range values { + got[values[i]] = fields[i] + } + // 值 → 直接父键名 + want := map[string]string{"hi": "html", "x": "content", "p": "arr", "q": "arr"} + for v, f := range want { + if got[v] != f { + t.Errorf("值 %q 的父键=%q,期望 %q", v, got[v], f) + } + } + // 数字值不提取 + if _, ok := got["1"]; ok { + t.Error("数字值不应被提取") + } +} + +// S1 二期:HTML 实体 + JS unicode 解码 +func TestNormalizeForXSSDetection(t *testing.T) { + cases := []struct{ in, wantContains string }{ + {"<script>alert(1)</script>", "`, "