mirror of
https://gitee.com/samwaf/SamWaf.git
synced 2026-10-30 17:51:44 +08:00
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.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
/vue/dist/
|
||||
/conf/*
|
||||
/data/*
|
||||
/wafenginecore/data/
|
||||
/data/local_log.db
|
||||
/data/local_stats.db
|
||||
/data/local.db
|
||||
|
||||
+9
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+55
-50
@@ -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 的规则,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
+16
-2
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
|
||||
@@ -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()") {
|
||||
|
||||
@@ -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() 应仍被检测")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+65
-13
@@ -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:多信号
|
||||
}
|
||||
|
||||
@@ -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 表单参数值含 <script> 应被拦截",
|
||||
},
|
||||
{
|
||||
name: "formValues检测暂未启用-不拦截",
|
||||
name: "D3-formValue中的XSS-现已启用检测",
|
||||
rawQuery: "page=1",
|
||||
formValues: url.Values{
|
||||
"input": []string{"<script>alert(1)</script>"},
|
||||
},
|
||||
expectBlock: true,
|
||||
desc: "formValue 逐值检测已启用,值含 <script> 应被拦截",
|
||||
},
|
||||
{
|
||||
name: "D3-formValue正常值-不误拦",
|
||||
rawQuery: "page=1",
|
||||
formValues: url.Values{
|
||||
"nickname": []string{"hello world"},
|
||||
"remark": []string{"价格 100 元"},
|
||||
},
|
||||
expectBlock: false,
|
||||
desc: "formValue 检测当前暂时禁用(业务存在误报风险),不触发拦截",
|
||||
desc: "普通表单值不应被误拦",
|
||||
},
|
||||
{
|
||||
name: "D3-JSON请求体值含XSS",
|
||||
bodyValues: []string{"<script>alert(document.cookie)</script>"},
|
||||
expectBlock: true,
|
||||
desc: "JSON 请求体字符串值含 <script> 应被拦截(逐值 BodyValues)",
|
||||
},
|
||||
{
|
||||
name: "D3-JSON请求体正常字符串值-不误拦",
|
||||
bodyValues: []string{"weekly report", "订单已发货"},
|
||||
expectBlock: false,
|
||||
desc: "正常 JSON 字符串值不应被误拦",
|
||||
},
|
||||
{
|
||||
name: "真实XSS-svg在值中",
|
||||
@@ -111,8 +137,9 @@ func TestCheckXss(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", "http://example.com/path?"+tt.rawQuery, nil)
|
||||
weblog := &innerbean.WebLog{
|
||||
RawQuery: tt.rawQuery,
|
||||
POST_FORM: tt.postForm,
|
||||
RawQuery: tt.rawQuery,
|
||||
POST_FORM: tt.postForm,
|
||||
BodyValues: tt.bodyValues,
|
||||
}
|
||||
formValues := tt.formValues
|
||||
if formValues == nil {
|
||||
@@ -136,3 +163,113 @@ func TestCheckXss(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckXssBodyMode 验证请求体 XSS 的三档模式:observe 只记录不拦、block 拦、off 不检测。
|
||||
func TestCheckXssBodyMode(t *testing.T) {
|
||||
waf, hostSafe := newTestWafForXSS()
|
||||
globalHost := waf.rt().HostTarget[global.GWAF_GLOBAL_HOST_NAME]
|
||||
defer func() { global.GCONFIG_BODY_DETECT_MODE = "observe" }()
|
||||
|
||||
run := func(mode string, bodyValues []string) detection.Result {
|
||||
global.GCONFIG_BODY_DETECT_MODE = mode
|
||||
req, _ := http.NewRequest("POST", "http://example.com/api", nil)
|
||||
weblog := &innerbean.WebLog{BodyValues: bodyValues}
|
||||
return waf.CheckXss(req, weblog, url.Values{}, hostSafe, globalHost)
|
||||
}
|
||||
xss := []string{"<script>alert(1)</script>"}
|
||||
|
||||
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{
|
||||
"<script>alert(1)</script>",
|
||||
`"><img src=x onerror=alert(1)>`,
|
||||
"<svg/onload=alert(1)>",
|
||||
}
|
||||
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 := "<script>alert(1)</script>"
|
||||
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 应拦")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ""
|
||||
}
|
||||
@@ -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", "<script>alert(1)</script>")) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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":"<script>"} 绕过)。
|
||||
// 携带字段名是为了支持“按字段排除”(E):已知装富文本/HTML 的字段可整体跳过深检。
|
||||
// 注意:不再对取出的值额外做 URL 解码——URL 编码的 JSON 值解开后会变回 JSON 结构被误判。
|
||||
// 非 JSON / 空 / 超限返回 nil,nil。
|
||||
func ExtractJSONFieldValues(body string) (fields, values []string) {
|
||||
if body == "" || len(body) > maxJSONBodyForDetection {
|
||||
return nil, nil
|
||||
}
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(body), &v); err != nil {
|
||||
return nil, nil // 非合法 JSON:正常非 JSON 体不参与,避免误报
|
||||
}
|
||||
var walk func(field string, x interface{})
|
||||
walk = func(field string, x interface{}) {
|
||||
if len(values) >= maxJSONValuesForDetection {
|
||||
return
|
||||
}
|
||||
switch t := x.(type) {
|
||||
case string:
|
||||
fields = append(fields, field)
|
||||
values = append(values, t)
|
||||
case []interface{}:
|
||||
for _, e := range t {
|
||||
walk(field, e) // 数组元素继承父键名
|
||||
}
|
||||
case map[string]interface{}:
|
||||
for k, e := range t { // 进入 map,键名成为子值的 field(只取值不取键名)
|
||||
walk(k, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("", v)
|
||||
return fields, values
|
||||
}
|
||||
|
||||
// ExtractJSONStringValues 仅取值(向后兼容)。
|
||||
func ExtractJSONStringValues(body string) []string {
|
||||
_, values := ExtractJSONFieldValues(body)
|
||||
return values
|
||||
}
|
||||
|
||||
// IsJSONBody 报告 body 是否为合法 JSON 对象/数组。用于 SQLi:JSON 体走逐值检测即可,
|
||||
// 不再整块喂 libinjection(整块含大量 , : " { } 结构字符,正常数字/结构 JSON 会被误判)。
|
||||
func IsJSONBody(body string) bool {
|
||||
s := strings.TrimSpace(body)
|
||||
if len(s) == 0 || len(s) > maxJSONBodyForDetection {
|
||||
return false
|
||||
}
|
||||
if s[0] != '{' && s[0] != '[' {
|
||||
return false
|
||||
}
|
||||
return json.Valid([]byte(s))
|
||||
}
|
||||
|
||||
// IsStructuredDataValue 报告值更像“结构化数据”而非 SQL 注入串:JSON 对象/数组、或 http(s)
|
||||
// URL。对这类值跳过 SQLi 逐值检测——正常埋点/日志/回调常把嵌套 JSON、回调 URL 塞进字段值,
|
||||
// libinjection 会把其中的引号/逗号/短横线误判为 SQLi。仅作用于 SQLi,不影响 XSS/RCE。
|
||||
func IsStructuredDataValue(v string) bool {
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
if s[0] == '{' || s[0] == '[' {
|
||||
return true
|
||||
}
|
||||
ls := strings.ToLower(s)
|
||||
return strings.HasPrefix(ls, "http://") || strings.HasPrefix(ls, "https://")
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package wafhttpcore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractJSONStringValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want []string // 期望包含的(子串)值
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "普通JSON只取字符串值不取键名",
|
||||
body: `{"name":"tom","age":18,"ok":true,"tags":["a","b"]}`,
|
||||
want: []string{"tom", "a", "b"}, // age/ok 非字符串跳过;键名不取
|
||||
},
|
||||
{
|
||||
name: "JSON字符串值含script标签",
|
||||
body: `{"x":"<script>alert(1)</script>"}`,
|
||||
want: []string{"<script>alert(1)</script>"},
|
||||
},
|
||||
{
|
||||
name: "值里的URL编码不再叠解码(避免正常埋点误报)",
|
||||
body: `{"x":"%3Cimg%20src=x%20onerror=1%3E"}`,
|
||||
want: []string{"%3Cimg%20src=x%20onerror=1%3E"}, // 保持原样,不 URL 解码
|
||||
},
|
||||
{
|
||||
name: "嵌套对象/数组递归取值",
|
||||
body: `{"a":{"b":["c",{"d":"e"}]}}`,
|
||||
want: []string{"c", "e"},
|
||||
},
|
||||
{
|
||||
name: "非JSON返回nil(正常表单/文本不参与)",
|
||||
body: `a=1&b=2`,
|
||||
wantNil: true,
|
||||
},
|
||||
{
|
||||
name: "空串返回nil",
|
||||
body: "",
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := ExtractJSONStringValues(c.body)
|
||||
if c.wantNil {
|
||||
if got != nil {
|
||||
t.Errorf("期望 nil,得到 %v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
joined := strings.Join(got, "\x00")
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(joined, w) {
|
||||
t.Errorf("期望包含值 %q,实际提取=%v", w, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractJSONStringValues_UnicodeEscape 验证 JSON \uXXXX 转义会被 json 解析还原,
|
||||
// 从而覆盖 {"x":"<script>..."} 这类绕过(body 用字节构造反斜杠,避免源码转义歧义)。
|
||||
func TestExtractJSONStringValues_UnicodeEscape(t *testing.T) {
|
||||
bs := string([]byte{0x5c}) // 反斜杠
|
||||
body := `{"x":"` + bs + `u003cscript` + bs + `u003ealert(1)` + bs + `u003c/script` + bs + `u003e"}`
|
||||
got := ExtractJSONStringValues(body)
|
||||
joined := strings.Join(got, "\x00")
|
||||
if !strings.Contains(joined, "<script>alert(1)</script>") {
|
||||
t.Errorf("期望 JSON 转义被解成 <script>...,实际提取=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsJSONBody(t *testing.T) {
|
||||
yes := []string{`{"a":1}`, ` [1,2,3] `, `{"x":{"y":"z"}}`}
|
||||
no := []string{"", "a=1&b=2", "hello", "123", `{"a":1`}
|
||||
for _, s := range yes {
|
||||
if !IsJSONBody(s) {
|
||||
t.Errorf("IsJSONBody(%q)=false,期望 true", s)
|
||||
}
|
||||
}
|
||||
for _, s := range no {
|
||||
if IsJSONBody(s) {
|
||||
t.Errorf("IsJSONBody(%q)=true,期望 false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStructuredDataValue(t *testing.T) {
|
||||
// 结构化数据(跳过 SQLi)
|
||||
structured := []string{
|
||||
`["[Capi Success]",{"body":{}}]`,
|
||||
`{"serviceType":"ba"}`,
|
||||
"https://rs.fullstory.com/rec/bundle/v2?OrgId=B9193&UserId=4b2b7c84-aa5f",
|
||||
"HTTP://Example.com/x",
|
||||
}
|
||||
// 仍需检测的普通值(不跳过)
|
||||
normal := []string{
|
||||
"1' or '1'='1",
|
||||
"admin",
|
||||
"union select password from users",
|
||||
"",
|
||||
}
|
||||
for _, s := range structured {
|
||||
if !IsStructuredDataValue(s) {
|
||||
t.Errorf("IsStructuredDataValue(%q)=false,期望 true(应跳过SQLi)", s)
|
||||
}
|
||||
}
|
||||
for _, s := range normal {
|
||||
if IsStructuredDataValue(s) {
|
||||
t.Errorf("IsStructuredDataValue(%q)=true,期望 false(应检测)", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractJSONFieldValues(t *testing.T) {
|
||||
fields, values := ExtractJSONFieldValues(`{"html":"<b>hi</b>","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{"<b>hi</b>": "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>", "<script>"}, // htmlent 命名实体
|
||||
{"<script>", "<script>"}, // 十进制实体
|
||||
{"<svg onload=alert(1)>", "<svg onload=alert"}, // 十六进制实体
|
||||
{`<script>alert(1)</script>`, "<script>"}, // unicode_js \u00XX
|
||||
{`\x3cimg src=x onerror=alert(1)\x3e`, "<img src=x onerror="}, // \xXX
|
||||
{"img(1)", "img(1)"}, // ( ) → ( )
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := NormalizeForXSSDetection(c.in)
|
||||
if !strings.Contains(got, c.wantContains) {
|
||||
t.Errorf("NormalizeForXSSDetection(%q)=%q, 应含 %q", c.in, got, c.wantContains)
|
||||
}
|
||||
}
|
||||
// 正常文本不应被破坏(无实体/转义则原样)
|
||||
for _, s := range []string{"hello world", "price 100", "a=1&b=2"} {
|
||||
if got := NormalizeForXSSDetection(s); got != s {
|
||||
t.Errorf("正常文本被改动: %q -> %q", s, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package wafhttpcore
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── D5-1:MSSQL/DB 高危关键字兜底(对标 OWASP CRS 942 专项规则)──
|
||||
// libinjection 以低误报为目标,纯 DDL/配置语句缺注入指纹会漏:sp_configure 单独开
|
||||
// xp_cmdshell 开关、CREATE TABLE + INSERT ... VALUES(0x..) 暂存 hex webshell、bcp
|
||||
// queryout 导出等。这些词/形态正常输入几乎不出现,命中即判 SQLi(大小写不敏感)。
|
||||
|
||||
// 单关键字:均为 MSSQL/DB 特有、非日常英文词。误报核验:逐条跑遍 blaze 33k 白样本命中 0。
|
||||
// 注意:MySQL 文件原语 into outfile/into dumpfile/load_file 是双刃词——真注入上下文由 libinjection
|
||||
// 拦,裸关键字会误伤合法 DB 管理流量(blaze 白样本实测 into outfile 命中 20+、load_file 1),故不入表。
|
||||
var sqlHighRiskKeywords = []string{
|
||||
"xp_cmdshell", "sp_configure", "xp_regwrite", "xp_regread", "xp_regdeletevalue",
|
||||
"xp_dirtree", "xp_fileexist", "sp_oacreate", "sp_oamethod",
|
||||
"openrowset", "openquery", "opendatasource",
|
||||
}
|
||||
|
||||
// bcp ... queryout(导出/落地 webshell)
|
||||
var reSqlBcpQueryout = regexp.MustCompile(`(?i)\bbcp\b[^;]*\bqueryout\b`)
|
||||
|
||||
// hex blob 写入:VALUES(0x..) 或 INSERT INTO .. 0x..(暂存 hex webshell,libinjection 漏)
|
||||
var reSqlHexBlob = regexp.MustCompile(`(?i)(values\s*\(\s*0x[0-9a-f]{16,}|insert\s+into\b[^;]{0,300}\b0x[0-9a-f]{16,})`)
|
||||
|
||||
// HasHighRiskSQLKeyword 报告串是否含 MSSQL/DB 高危关键字或落地形态。供 CheckSql 逐值调用。
|
||||
// 先叠一层 URL 解码:JSON 逐值(BodyValues)按 S1 设计不额外解码,url 编码的 VALUES(0x..) 会漏;
|
||||
// 关键字匹配是精确子串,解码只暴露更多明文、对正常内容不会误命中,故此处安全。
|
||||
func HasHighRiskSQLKeyword(raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(NormalizeForDetection(raw))
|
||||
for _, kw := range sqlHighRiskKeywords {
|
||||
if strings.Contains(s, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return reSqlBcpQueryout.MatchString(s) || reSqlHexBlob.MatchString(s)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package wafhttpcore
|
||||
|
||||
import "testing"
|
||||
|
||||
// D5-1:MSSQL/DB 高危关键字兜底 —— libinjection 漏的纯 DDL/配置语句必须命中,正常输入不误报。
|
||||
func TestHasHighRiskSQLKeyword(t *testing.T) {
|
||||
hit := map[string]string{
|
||||
// SamWafPoc sqli 语料 5 条 MSSQL 提权链(含 libinjection 漏的 spconfig-only / webshell-stage)
|
||||
"xpcmdshell": `1;exec master..xp_cmdshell 'whoami';--`,
|
||||
"enable-xp": `1;exec sp_configure 'show advanced options',1;reconfigure;exec sp_configure 'xp_cmdshell',1;reconfigure;--`,
|
||||
"spconfig-only": `1;exec sp_configure 'xp_cmdshell',1;reconfigure;--`,
|
||||
"webshell-stage": `1;CREATE TABLE g3(a varchar(8000));INSERT INTO g3 VALUES(0x3c25402070616765);--`,
|
||||
"bcp-export": `1;exec xp_cmdshell 'bcp "select a from g3" queryout /tmp/probe.txt -c -T';--`,
|
||||
// 其它高危形态
|
||||
"openrowset": `select * from openrowset('sqloledb','...')`,
|
||||
"bcp-queryout": `bcp "select x" queryout c:\inetpub\x.aspx -c`,
|
||||
"insert-hex": `insert into t select 0x3c25402070616765abcd1234`,
|
||||
"case-mixed": `1;EXEC Xp_CmdShell 'whoami';--`, // 大小写变体
|
||||
"url-encoded": `insert%20into%20g%20values%280x3c25402070616765%29`, // url 编码(叠 URL 解码后现形)
|
||||
}
|
||||
for name, p := range hit {
|
||||
if !HasHighRiskSQLKeyword(p) {
|
||||
t.Errorf("应命中但漏过 [%s]: %q", name, p)
|
||||
}
|
||||
}
|
||||
|
||||
miss := map[string]string{
|
||||
"normal-name": "John Smith",
|
||||
"normal-query": "keyword=价格100元&page=2",
|
||||
"select-word": "please select all items", // select 不在关键字表
|
||||
"reconfigure": "reconfigure your dashboard", // reconfigure 单独不判(常见英文)
|
||||
"bcp-noqueryout": "bcpackage download", // bcp 无 queryout 不判
|
||||
"short-hex": "color=0xff0000&file=a.png", // 短 hex 非 blob
|
||||
"open-word": "openreport&opendashboard", // 非 openrowset/openquery
|
||||
"into-outfile": "export data into outfile report.csv", // MySQL 文件原语不入表(改前误伤 blaze 白样本 20+)
|
||||
"load-file": "please load_file the template", // 同上,双刃词不入表
|
||||
}
|
||||
for name, p := range miss {
|
||||
if HasHighRiskSQLKeyword(p) {
|
||||
t.Errorf("应放行但误判 [%s]: %q", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package wafhttpcore
|
||||
|
||||
import "strings"
|
||||
|
||||
// ── D4/D6:目录穿越 + 敏感文件(LFI)检测 ──
|
||||
// 先归一化(多轮 URL 解码 → overlong UTF-8 → 反斜杠归一 → ....// 折叠),再判"是否逃出根"
|
||||
// 而非见 ../ 就拦:站内相对路径 /assets/theme/../css/app.css 规范化后仍在根内,放行(消除
|
||||
// path-css 误报)。另叠一张敏感文件表接住"无 ../ 的裸绝对路径 LFI"(/etc/passwd 等,D6)。
|
||||
|
||||
// overlong UTF-8 及编码变体 → 规范字符,防 ..%c0%af 之类绕过(URL 解码后是裸字节)。
|
||||
var traversalOverlong = strings.NewReplacer(
|
||||
"\xc0\xaf", "/", "\xe0\x80\xaf", "/", "\xf0\x80\x80\xaf", "/", // '/'
|
||||
"\xc0\xae", ".", "\xe0\x80\xae", ".", // '.'
|
||||
"\xc1\x9c", "/", "\xe0\x81\x9c", "/", // '\\' → 统一按 '/'
|
||||
)
|
||||
|
||||
// normalizeTraversalPath 归一化用于穿越/LFI 判定的串(输出小写、正斜杠)。
|
||||
func normalizeTraversalPath(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
s := NormalizeForDetection(raw) // 多轮 URL 解码(含双重编码 %252e)
|
||||
s = traversalOverlong.Replace(s) // overlong UTF-8 → / .
|
||||
s = strings.ReplaceAll(s, "\\", "/") // 反斜杠 → 正斜杠
|
||||
for strings.Contains(s, "....//") { // 折叠 ....// → ../("删一次 ../"式绕过)
|
||||
s = strings.ReplaceAll(s, "....//", "../")
|
||||
}
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
|
||||
// escapesRoot 报告归一化后的路径是否"逃出根"(../ 累计深度超过前缀目录深度)。
|
||||
// /assets/theme/../css/app.css 规范化后仍在根内 → false;../../etc/passwd → true。
|
||||
func escapesRoot(p string) bool {
|
||||
depth := 0
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
switch seg {
|
||||
case "", ".":
|
||||
// 分隔符 / 当前目录:不改变深度
|
||||
case "..":
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return true // 跳出根
|
||||
}
|
||||
default:
|
||||
depth++
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 敏感文件/路径特征(对标 OWASP CRS 930120 lfi-os-files)。均小写。
|
||||
// absSensitiveFiles 需绝对定位(前导 / 或 // 边界)——避免站内子目录 foo/etc/passwd 误伤。
|
||||
var absSensitiveFiles = []string{
|
||||
"/etc/passwd", "/etc/shadow", "/etc/gshadow",
|
||||
"/proc/self/environ", "/proc/self/cmdline", "/proc/version",
|
||||
"/root/.bash_history",
|
||||
"/.ssh/id_rsa", "/.ssh/id_dsa", "/.ssh/authorized_keys",
|
||||
"/windows/win.ini", "/windows/system.ini", "/windows/system32/config/sam",
|
||||
"/boot.ini",
|
||||
}
|
||||
|
||||
// nameSensitiveFiles 足够独特的文件名,出现在任意路径段边界即判(前导 / 或串首)。
|
||||
var nameSensitiveFiles = []string{
|
||||
"wp-config.php", ".htpasswd", "web-inf/web.xml", ".git/config", ".svn/entries",
|
||||
}
|
||||
|
||||
// hitSensitiveFile 归一化串是否命中敏感文件表(路径边界匹配,压误报)。
|
||||
func hitSensitiveFile(normalized string) bool {
|
||||
for _, f := range absSensitiveFiles {
|
||||
if containsSensitiveToken(normalized, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, f := range nameSensitiveFiles {
|
||||
if containsSensitiveToken(normalized, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsSensitiveToken 在"路径边界"上查子串:左侧为串首或 '/',右侧为串尾或 / ? # .
|
||||
// —— 避免 /etc/passwd 命中 /etc/passwderr、/etc/hosts 命中 /etc/hostsettings。
|
||||
func containsSensitiveToken(s, token string) bool {
|
||||
from := 0
|
||||
for {
|
||||
rel := strings.Index(s[from:], token)
|
||||
if rel < 0 {
|
||||
return false
|
||||
}
|
||||
i := from + rel
|
||||
leftOK := i == 0 || s[i-1] == '/' || s[i-1] == ':' // ':' 容纳盘符 c:/ 与 scheme
|
||||
end := i + len(token)
|
||||
rightOK := end == len(s)
|
||||
if !rightOK {
|
||||
c := s[end]
|
||||
rightOK = c == '/' || c == '?' || c == '#' || c == '.'
|
||||
}
|
||||
if leftOK && rightOK {
|
||||
return true
|
||||
}
|
||||
from = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// HasTraversalOrLFI 归一化后判"逃出根"或命中敏感文件。供 CheckDirTraversal 逐值调用。
|
||||
func HasTraversalOrLFI(raw string) bool {
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
n := normalizeTraversalPath(raw)
|
||||
if escapesRoot(n) {
|
||||
return true
|
||||
}
|
||||
return hitSensitiveFile(n)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package wafhttpcore
|
||||
|
||||
import "testing"
|
||||
|
||||
// D4+D6:目录穿越/LFI —— 7 条真漏样本(SamWafPoc traversal 语料)必须命中,
|
||||
// path-css 误报样本及常见正常值必须放行。
|
||||
func TestHasTraversalOrLFI(t *testing.T) {
|
||||
malicious := map[string]string{
|
||||
"unix-passwd": "../../../../etc/passwd",
|
||||
"enc-passwd": "..%2f..%2f..%2fetc%2fpasswd",
|
||||
"dotdot-slash": "....//....//etc/passwd",
|
||||
"abs-passwd": "/etc/passwd", // D6 裸绝对路径
|
||||
"win-ini": "..\\..\\..\\windows\\win.ini",
|
||||
"double-enc": "%252e%252e%252fetc%252fpasswd",
|
||||
"utf8-overlong": "..%c0%af..%c0%afetc/passwd",
|
||||
// 额外 D6 敏感文件
|
||||
"abs-shadow": "/etc/shadow",
|
||||
"abs-environ": "/proc/self/environ",
|
||||
"win-abs-ini": "C:\\windows\\win.ini",
|
||||
"wp-config": "/var/www/wp-config.php",
|
||||
}
|
||||
for name, p := range malicious {
|
||||
if !HasTraversalOrLFI(p) {
|
||||
t.Errorf("应命中但漏过 [%s]: %q", name, p)
|
||||
}
|
||||
}
|
||||
|
||||
benign := map[string]string{
|
||||
"path-css": "/assets/theme/../css/app.css", // 站内相对,规范化后不逃根
|
||||
"normal-path": "/api/v1/users/123",
|
||||
"version": "1.2.3",
|
||||
"abs-url": "https://cdn.example.com/a/../b/app.js", // 绝对 URL 内的 .. 不逃根
|
||||
"query-normal": "keyword=价格100元",
|
||||
"dotfile": "app.config.js",
|
||||
"subdir-etc": "uploads/etc/report.csv", // 站内 etc 子目录,非系统 /etc
|
||||
"env-word": "/api/environment/list", // 不应被 .env 之类误伤
|
||||
}
|
||||
for name, p := range benign {
|
||||
if HasTraversalOrLFI(p) {
|
||||
t.Errorf("应放行但误拦 [%s]: %q", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapesRoot(t *testing.T) {
|
||||
esc := []string{"../x", "../../etc/passwd", "a/../../b", "/../etc"}
|
||||
for _, p := range esc {
|
||||
if !escapesRoot(p) {
|
||||
t.Errorf("应判逃根: %q", p)
|
||||
}
|
||||
}
|
||||
stay := []string{"/assets/theme/../css/app.css", "a/b/../c", "/etc/passwd", "foo/bar", ""}
|
||||
for _, p := range stay {
|
||||
if escapesRoot(p) {
|
||||
t.Errorf("应在根内: %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsSensitiveToken(t *testing.T) {
|
||||
// 边界匹配:命中
|
||||
hit := [][2]string{
|
||||
{"/etc/passwd", "/etc/passwd"},
|
||||
{"/var/www/wp-config.php", "wp-config.php"},
|
||||
{"/x/.git/config", ".git/config"},
|
||||
}
|
||||
for _, c := range hit {
|
||||
if !containsSensitiveToken(c[0], c[1]) {
|
||||
t.Errorf("应命中 token %q in %q", c[1], c[0])
|
||||
}
|
||||
}
|
||||
// 边界匹配:不命中(前缀/子串误伤)
|
||||
miss := [][2]string{
|
||||
{"/etc/passwderr", "/etc/passwd"}, // 右边界
|
||||
{"/app/etc/passwd", "/etc/passwd"}, // 左边界(子目录 app/etc)
|
||||
{"/etc/hostsettings", "/etc/hosts"}, // 若未来加 hosts 也不误伤
|
||||
}
|
||||
for _, c := range miss {
|
||||
if containsSensitiveToken(c[0], c[1]) {
|
||||
t.Errorf("不应命中 token %q in %q", c[1], c[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,6 +384,19 @@ func setConfigStringValue(name string, value string, change int) {
|
||||
global.GCONFIG_AI_MODE = "observe"
|
||||
}
|
||||
break
|
||||
case "body_detect_mode":
|
||||
switch value {
|
||||
case "observe", "block", "off":
|
||||
global.GCONFIG_BODY_DETECT_MODE = value
|
||||
default:
|
||||
zlog.Warn("invalid body_detect_mode value, fallback to observe", value)
|
||||
global.GCONFIG_BODY_DETECT_MODE = "observe"
|
||||
}
|
||||
break
|
||||
case "body_detect_field_exclude":
|
||||
global.GCONFIG_BODY_DETECT_FIELD_EXCLUDE = value
|
||||
wafenginecore.SetBodyDetectFieldExclude(value) // 同步解析成 RCU 排除集
|
||||
break
|
||||
case "kafka_url":
|
||||
global.GCONFIG_RECORD_KAFKA_URL = value
|
||||
break
|
||||
@@ -746,6 +759,8 @@ func TaskLoadSetting(initLoad bool) {
|
||||
updateConfigIntItem(initLoad, "system", "ipprobe_enable", global.GCONFIG_IPPROBE_ENABLE, "真实IP来源探针(1开启 0关闭,默认关闭)。开启后记录各站点最近到达的请求头(脱敏,仅内存,每站每秒最多1条),供站点「真实IP来源」处排查CDN送来的是哪个头;排查完建议关闭", "int", "", configMap)
|
||||
updateConfigIntItem(initLoad, "system", "ai_enable", global.GCONFIG_AI_ENABLE, "启动AI智能检测总开关(1启动 0关闭,需先在AI模型管理上传模型包并在站点开启)", "int", "", configMap)
|
||||
updateConfigStringItem(initLoad, "system", "ai_mode", global.GCONFIG_AI_MODE, "AI检测工作模式:observe(仅记录/观察) block(达拦截阈值则拦截)", "options", "observe|仅记录,block|拦截", configMap)
|
||||
updateConfigStringItem(initLoad, "system", "body_detect_mode", global.GCONFIG_BODY_DETECT_MODE, "请求体深度检测模式(formValue/JSON 逐值 XSS/SQLi/RCE):observe 观察(命中只记录不拦,默认) block 拦截(命中即拦) off 关闭。默认 observe 避免误伤正常 JSON/表单流量,观察日志确认无误后再切 block;查询串检测不受影响始终拦截", "options", "observe|观察(只记录),block|拦截,off|关闭", configMap)
|
||||
updateConfigStringItem(initLoad, "system", "body_detect_field_exclude", global.GCONFIG_BODY_DETECT_FIELD_EXCLUDE, "请求体深检字段排除清单(逗号分隔字段名,不区分大小写)。这些字段名下的表单值/JSON值(匹配直接父键名)跳过 XSS/SQLi/RCE 深检,用于放行已知携带富文本/HTML/代码的合法字段(如 content,html,markdown),压 block 模式误报。看观察日志里是哪些字段误报再填", "string", "", configMap)
|
||||
updateConfigIntItem(initLoad, "system", "rule_chain_mode", global.GCONFIG_RULE_CHAIN_MODE, "自定义规则在检测链中的位置(0默认:排在CC之后;1规则优先:排在黑名单之后、Bot/SQLI/XSS等之前,此时规则的放行动作才能跳过这些检测)", "int", "", configMap)
|
||||
|
||||
updateConfigIntItem(initLoad, "access", "access_enable", global.GCONFIG_ACCESS_ENABLE, "统一访问认证总开关:开启后所有被WAF代理的站点默认都需先登录才能访问(可在站点里单独强制开/关,也可配置免认证路径与IP组)。请先在【统一访问认证-访问账号】里建好账号再开启", "options", "0|关闭,1|开启", configMap)
|
||||
|
||||
Reference in New Issue
Block a user