一、概述
W3C 访问日志是 IIS 上唯一的逐请求记录,也是全案密度最高、最容易被噪声淹没的证据。
它不含任何人工行为推断,只有服务器工作线程按固定格式吐出的请求事实:谁、什么时候、请求了什么、结果如何、耗时多久。
事件日志记的是系统级事件,注册表记的是配置现状——只有访问日志能证明"这个具体的攻击请求确实到达过这台服务器,并且得到了这样的响应"。
但这份证据的密度与分析要求完全不匹配。
中等规模站点日均请求量在几十万条量级,逐条人眼看不动;而真正有意义的信息——"哪一批是自动化攻击、哪一次返回 200 说明落地成功"——埋在几十万条 200 0 0 里。
难点在于把噪声压下去。
本篇给出的三层判读方法:
- 状态码三元组——
sc-status / sc-substatus / sc-win32-status 的组合语义。这是 IIS 独有的信息维度,比单看状态码精确得多,能区分"文件真不存在"和"路径语法错了"这种看似同为 404 的情况。
- 攻击特征的日志指纹——SQL 注入、路径穿越、上传落地、目录扫描、凭据爆破、脚本调用,各自对应不同的字段组合模式。
- 风险打分脚本——把上述判读固化成可运行、可复现、可调阈值的自动化流程,输出 Top N 可疑请求并附命中原因。
在取证流程中的位置:本篇属于分析环的归因层,输入是已解析的 W3C 结构化记录,输出是"哪些请求可疑"的候选集。
它的产出是候选,不是结论——每个候选都要在 3.3 第 3 步 人工复核,并在主机侧印证后才进入报告。
上游依赖 IIS 配置与 W3C 日志——本篇假设你已读完它并能正确解析(字段表可信、时区已换算、状态码两层含义已分清,这三条没做到,每个统计数字都是错的而且不会报错);下游接上 Web 攻击痕迹还原 的主机侧印证。
涉及的证据形态(具体到字段,不是"访问日志"这种大类):
| 维度 |
字段 |
判读价值 |
| 状态码三元组 |
sc-status + sc-substatus + sc-win32-status |
★★★★★ 本篇最核心的判据 |
| 组合判读 |
404/1 = 未找到站点 |
★★★★★ 虚拟主机扫描的强信号 |
|
404/7 = 扩展名被拒 |
★★★★★ 攻击被 Request Filtering 挡下的直接证据 |
|
404/14 = URL 过长 |
★★★★ 缓冲区溢出类探测前兆 |
|
sc-win32-status=2 vs 3 vs 5 |
★★★★★ 文件不存在 / 路径不可达 / 存在但被拒 |
| 耗时 |
time-taken(毫秒) |
★★★★★ 时间盲注的唯一抓手 |
| 查询串 |
cs-uri-query:' OR 1=1--、UNION SELECT、SLEEP(5) |
★★★★★ 注入指纹 |
| 请求路径 |
cs-uri-stem:../、%2e%2e/、%252e(双重编码) |
★★★★ 路径穿越 |
| 方法 + 路径 |
PUT/PATCH 到 /upload* 且 .aspx |
★★★★★ 落地证据,响应 201 |
| UA |
sqlmap、Nikto、nuclei、python-requests |
★★ 只是佐证,不能单独定性 |
| 时间分布 |
间隔 P90−P10 抖动 < 5% |
★★★★★ 机器节拍,脚本发出的确证 |
| 客户端 IP |
c-ip 全是同一个 → 那是 CDN/代理 |
★★★★ 归因前的必查项 |
★ 本篇最重要的纠错:大量中文资料把 404.3 说成"Win32 找不到路径"。这是错的——sc-substatus=3 是 MIME 类型限制;"路径找不到"是 sc-win32-status=3(ERROR_PATH_NOT_FOUND),"文件找不到"才是 sc-win32-status=2。混用会得出"文件存在只是路径错了"这种反向结论。
哪些请求是自动化攻击而非人工浏览;某次攻击探测是否真的到达了 IIS 并被哪一层挡下(404/7 比"没落地"更有价值);时间盲注藏在哪条记录里;某次 PUT ... 201 是否意味着上传成功;哪些 IP、路径、UA 组合构成一条完整的攻击序列;攻击窗口的起止时间。
攻击是否利用成功——200 不等于代码执行成功,可能是错误页;请求体与响应体内容(W3C 不记录);攻击者的真实 IP(c-ip 在反代后面是代理,见 01 篇)。
主机的后续行为日志里也没有——日志到此为止,要看文件系统与事件日志。至于哪些请求被 sc-substatus 之外的静默失败路径吞掉,同样无从查证。
内容边界(本篇的红线):本篇处理的是日志侧的统计特征与攻击请求的识别,产出是"哪些请求可疑"这一候选集。它不提供任何攻击构造、载荷生成或利用步骤——所有特征描述都以"如何从字段组合中认出这类请求"的角度表述。
同理,看到 sqlmap UA 不等于可以断定"对方用 sqlmap 攻击了",它只是忘了改默认值的疏忽性证据。
读者前提:本篇硬依赖 IIS 配置与 W3C 日志——字段表、时区换算、状态码两层含义这三条前提没做到,本篇每一个统计数字都是错的,而且不报错。
事件 ID 基础与 Get-WinEvent 用法见事件日志深度分析。
另外需要 HTTP 状态码语义、正则表达式,以及对"标准差/分位数"这类统计量的直观理解(用来判读"间隔抖动 < 5%"这个判据)。
二、核心原理
2.1 ★ 状态码三元组:IIS 比 HTTP 多给了两路错误信息
标准 HTTP 只返回 404 这样一个状态码。
IIS 在此基础上额外记录两路独立的失败原因,这是它取证价值高于普通 Web 日志的根本原因:
| 字段 |
含义 |
取值空间 |
反映的问题 |
sc-status |
HTTP 状态码 |
100–599 |
协议层结论(对客户端说的话) |
sc-substatus |
IIS 细分子状态 |
各状态码下自定义 |
IIS 内部哪一步没通过 |
sc-win32-status |
Win32 系统错误码 |
Windows 错误码 |
操作系统层失败原因 |
★★★ 本篇最重要的纠错:大量中文资料把 404.3 说成"Win32 找不到路径"。这是错的——sc-substatus=3 是 MIME 类型限制;"路径找不到"是 sc-win32-status=3(ERROR_PATH_NOT_FOUND),"文件找不到"才是 sc-win32-status=2。混用会得出"文件存在只是路径错了"这种反向结论。** sc-substatus 侧组合判读**:
| 组合 |
含义 |
判读 |
404.0 |
未找到,文件已移动或不存在 |
真 404。资源真的不在这个路径上 |
404.1 |
未找到站点 |
请求的 Host 头没有对应绑定 → 虚拟主机扫描的强信号 |
404.2 |
MIME 类型限制 / 路径语法问题 |
请求路径形式上无法映射到文件 |
404.3 |
MIME 类型限制 |
扩展名映射无效或未配置(不是路径找不到) |
404.4 |
未配置处理程序 |
扩展名有 MIME 映射但没有对应 handler → 例如请求了 .php 但没装 PHP |
404.7 |
文件扩展名被拒绝 |
命中了 Request Filtering 的扩展名黑名单 → 攻击探测被 IIS 挡下的直接证据 |
404.14 |
请求 URL 太长 |
缓冲区溢出类探测的前兆,或畸形超长请求 |
400.1 |
请求头过长 |
超长 Header 探测 |
| 值 |
Win32 常量 |
含义 |
取证解读 |
0 |
ERROR_SUCCESS |
无系统级错误 |
失败发生在 IIS/应用层,不是文件系统问题 |
2 |
ERROR_FILE_NOT_FOUND |
找不到文件 |
路径是对的,只是文件不在 → 真 404 的确认信号 |
3 |
ERROR_PATH_NOT_FOUND |
找不到路径 |
路径本身不可达(中间目录不存在) |
5 |
ERROR_ACCESS_DENIED |
拒绝访问 |
权限或 Request Filtering 拦截 |
53 |
ERROR_BAD_NETPATH |
网络路径不存在 |
指向了 UNC 或已卸载的共享 |
判读口诀:sc-win32-status=0 + 5xx = 应用层自己崩了,与文件无关;sc-win32-status=2 + 404 = 文件确实不存在;sc-win32-status=5 + 404 = 存在但被拒绝——这一条最容易被误判成"文件不存在",实际是被权限或过滤规则挡住,而文件可能就在那儿。
2.2 攻击特征在日志中的落点
每类攻击都在特定字段上留下固定模式。把"攻击类型 → 字段模式"记住,比记正则表达式有用得多。
| 攻击类型 |
判读字段 |
特征模式 |
典型响应 |
| SQL 注入 |
cs-uri-query |
' OR 1=1--、id=1 UNION SELECT null,version()、id=1 AND SLEEP(5) |
500(语法错)、200 + time-taken 异常大(盲注)、200(直接回显) |
| 路径穿越 |
cs-uri-stem |
../、..%2f、%2e%2e/、..%5c、%252e(双重编码) |
404/2(无法映射)、200(读到了系统文件) |
| 上传落地 |
cs-method + 路径 |
PUT/PATCH、路径在 /upload*、扩展名 .aspx |
201 Created(成功)、405(不允许) |
| 目录扫描 |
批量 404 |
同一 IP 短时间大量不同路径全 404,路径呈规律递增 |
全部 404 |
| 凭据爆破 |
sc-status |
同一 IP 同一路径集中出现 401/403 |
401/403,末尾可能夹一个 302 |
| 脚本调用 |
cs-uri-query |
cmd=、exec=、shell=、eval= 等参数名 |
200 + 响应体极小 |
| Web 扫描器 |
cs(User-Agent) |
sqlmap、Nikto、nessus、acunetix、nuclei |
混合 |
| 反代穿透 |
c-ip |
全是同一个 IP → 那是 CDN/代理,不是真实来源 |
— |
★ 最有价值的单条判读:time-taken + SQL 注入。 时间盲注的注入请求返回 200,看起来完全正常——但 time-taken 会出现明显离群值。正常页面 time-taken 集中在几十毫秒,而 SLEEP(5) 那条是 5000+ 毫秒。在满是 200 的日志里,毫秒时延是唯一能把盲注揪出来的字段。 前提是不要把 time-taken 当秒(它是毫秒)。
2.3 User-Agent 与时间维度:两个最容易被滥用的维度
UA 是最容易被滥用的字段——攻击者可随意伪造,它本身不构成任何证据;而自动化发包很难带上随机抖动(工具默认固定 sleep)。
这两点必须同时理解。
| 维度 |
判读 |
可靠性 |
说明 |
| UA |
命中已知工具名(sqlmap/Nikto) |
★★★ |
攻击者忘了改默认值,属疏忽性证据,须与查询串互证 |
|
为空 |
★★ |
脚本化常见;也可能是极简客户端或反代剥离 |
|
python-requests/curl |
★★ |
自动化客户端。但备份脚本、监控探针、CI 也是这个 |
|
正常浏览器 UA 但行为异常 |
★★★★★ |
最有价值——伪装正常,正因如此才要看查询串 |
| 时间 |
深夜突增(00:00–05:00 偏离历史基线) |
★★★ |
业务此时本无流量 |
|
间隔 P90−P10 抖动 < 5% |
★★★★★ |
机器节拍,脚本发出的确证 |
|
每分钟数百请求且全 404 |
★★★ |
目录扫描 |
|
沉寂数小时后单次请求 |
★★ |
人工介入或 C2 心跳 |
陷阱:看到 curl/8.4.0 就写"攻击工具"是错的——UA 只是佐证,不能单独定性。
★ 为什么"间隔均匀"比"用了扫描器"更强:工具 UA 可随便改、IP 可换代理,但发包节拍是工具库的实现细节。一条伪装成 Chrome、但 500 个请求间隔标准差极小的记录,比 100 条写着 sqlmap 的记录更能说明自动化。
2.5 为什么需要打分而不是单纯 grep
朴素做法是每个特征跑一个 grep 再人工去重排序,但有三个硬伤:
- 一次命中不等于可疑——
union 也会出现在正常的技术文章页里;
- 强信号往往不带单条特征——自动化扫描的每一条单独看都是普通 404,只有聚合后才显出"几百个不同路径全是 404";
- 强信号常是弱特征的叠加——空 UA(3 分)+ 无 Referer(3 分)+ 深夜(2 分)单条都不起眼,加起来就超过一条真正的 SQL 注入。
打分脚本的价值就是把"弱信号叠加 + 跨行聚合"固化下来,并且输出命中原因——评审时能直接看到"为什么这条是 31 分",而不是一个黑箱数字。
三、操作步骤
3.1 第 1 步:确认分析目标与时间基准
从镜像提取 W3SVC* 下的 *.log*(完整命令见第七章),随后确认两件事:
head -4 /evidence/u_ex241114.log # #Software 判版本,#Fields 判字段顺序
ls -l /evidence/W3SVC2/ # ★ 体积异常 = 截断线索(见 04 篇)
验证:若同一文件出现多行 #Fields,说明当天改过日志配置(见 02)。做任何统计之前先确认时区——W3C 的 date/time 是 UTC。
3.2 第 2 步:运行风险打分脚本
脚本仅用 Python 3.6+ 标准库,无需 pip install 任何东西。保存为 w3c_risk.py:
#!/usr/bin/env python3
-- coding: utf-8 --
"""IIS W3C 访问日志风险打分器:逐行规则匹配 + 4 个跨行聚合规则,输出 Top N 可疑请求。
用法: python3 w3c_risk.py <日志文件或目录...> [-n 20] [--tz +8] [--min 1] [-o risk.csv]
依赖: 仅 Python 3.6+ 标准库(无第三方包)"""
import argparse, glob, os, re, sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from urllib.parse import unquote
===== 规则表 (规则码, 分值, 范围, 正则, 说明) =====
范围: t=解码后的"路径+查询串" tp=t 再把 '+' 还原为空格 s=路径 ua=User-Agent m=方法
RULES = [
# --- SQL 注入 ---
("SQLI-UNION", 10, "tp", r"union\s+(?:all\s+|distinct\s+)?select", "URL 中拼接 union select"),
("SQLI-BOOL", 10, "tp", r"(?:'|"|\b)\s*(?:or|and)\s+['"]?\w+['"]?\s*=\s*['"]?\w+", "恒真/恒假布尔条件"),
("SQLI-COMMENT", 8, "tp", r"(?:'|")?\s*(?:--|#|/*)\s*$|'\s*(?:--|#)", "SQL 注释截断后条件"),
("SQLI-TIME", 10, "tp", r"\b(?:sleep|benchmark|pg_sleep)\s*(|\bwaitfor\s+delay", "延时函数(盲注)"),
("SQLI-INFO", 8, "tp", r"information_schema|sysobjects|syscolumns|sqlite_master|@@version", "库表结构枚举"),
# --- 路径穿越 / 远程执行 ---
("TRAVERSAL", 10, "tp", r"..[\/]|%2e%2e|..%2f|..%5c|%252e|boot.ini|win.ini", "路径穿越"),
("RCE-PARAM", 10, "tp", r"(?:^|[?&;])(?:cmd|exec|shell|command|eval|passthru|system|query)\s*=", "命令/执行类参数名"),
("RCE-SEP", 10, "tp", r"|\s*(?:cat|whoami|id|nslookup|ipconfig|tasklist)\b|%00|${jndi:", "管道命令/空字节截断/JNDI"),
# --- 落地与扫描 ---
("WEBSHELL-EXT", 10, "s", r".(?:aspx|ashx|asmx|axd|config|php[0-9]?|jsp|asp|cer|asax)(?:$|.)", "可执行/可配置扩展名"),
("UPLOAD-PATH", 5, "s", r"/(?:upload|upfile|files|attachment|tmp|temp)/", "上传类路径"),
("SCAN-PATH", 5, "s", r"/(?:wp-admin|phpmyadmin|/admin\b|/manager/html|/WEB-INF|/.git|/.svn|/.env|/backup|/cgi-bin|/solr|jenkins|actuator)", "常见扫描目标路径"),
# --- User-Agent ---
("UA-BLACK", 5, "ua", r"sqlmap|nikto|nessus|acunetix|wpscan|nuclei|masscan|nmap|dirbuster|gobuster|havij|arachni", "已知扫描/利用工具 UA"),
("UA-SCRIPT", 3, "ua", r"^(?:python-requests|python-urllib|curl|wget|go-http-client|okhttp|node-fetch|axios|apache-httpclient|libcurl)", "脚本化 HTTP 客户端 UA"),
("UA-EMPTY", 5, "ua", r"^$", "空 User-Agent"),
# --- 方法 ---
("METHOD-WRITE", 5, "m", r"^(?:PUT|PATCH|DELETE|MKCOL|MOVE|COPY|OPTIONS|TRACE|DEBUG)$", "写入/配置类 HTTP 方法"),
]
===== 聚合阈值(经验值,须按站点历史基线调整)=====
BURST_404, BRUTE_N, DIR_ENUM, REGULAR_N = 200, 10, 50, 12
def _i(x):
"""安全转 int:字段缺失或行被截断时返回 -1,绝不抛异常中断整个分析。"""
try:
return int(x)
except (TypeError, ValueError):
return -1
def status_rules(st, su