关键词:IIS、applicationHost.config、MetaBase、W3C 日志、#Fields、字段顺序、日志轮转、u_ex
难度:进阶
前置知识:HTTP 协议基本概念、XML 基础、Windows 服务与配置
相关文章:Windows 服务器取证工件地图、Web 访问日志深度分析
一、概述
IIS 取证有两个必须先解决的问题:这台服务器上有哪些站点、各自对应磁盘上哪个目录;以及这些站点的访问日志实际记录了哪些字段、按什么顺序排列。
第二个问题是新手最容易翻车的地方。W3C 日志看起来是"空格分隔的一行文本",很容易让人写死列索引去取字段。
但 IIS 的字段集合与顺序是可配置的——不同版本、不同管理员、不同时间点都不一样。
任何写死下标的解析器遇到非默认配置都会静默给出错误结果:把 IP 当成用户名、把状态码当时延。更糟的是它不报错,错误会一路流进取证报告。
换句话说,「这条日志说了什么」这个问题,在 IIS 上必须先答「这份日志的字段表是什么」才能回答。
在取证流程中的位置:本篇属于分析环的解析基座层,产出两件东西:站点拓扑表与可复用的日志解析器。
它不产出结论,而是决定后面每一篇的统计数字是否成立。
上游依赖 Windows 服务器取证工件地图 提供的工件边界与 事件日志深度分析 的通道检查;下游直接接上 Web 访问日志深度分析 的归因统计与 Web 攻击痕迹还原 的主机侧印证。
涉及的证据形态(具体到文件与字段,不是"访问日志"这种大类):
| 形态 |
路径 / 字段 |
取证意义 |
| IIS 7+ 全局配置 |
%windir%\system32\inetsrv\config\applicationHost.config(XML 明文) |
★★★★★ 站点拓扑的唯一权威来源 |
| 配置备份 |
applicationHost.config.bak |
★★★★★ 改配置前的版本,diff 即篡改证据 |
| IIS 6 配置 |
%windir%\system32\inetsrv\MetaBase.xml + MBSchema.xml |
★★★★★ 已经是 XML 明文,不是二进制 |
| IIS 4/5 配置 |
MetaBase.bin |
★★ 二进制且已不含有效配置,仅占位 |
| 站点配置 |
C:\inetpub\wwwroot\<site>\Web.config、App_Offline.htm |
★★★★★ 含 machineKey,攻防焦点 |
| 访问日志 |
C:\inetpub\logs\LogFiles\W3SVC<n>\u_ex*.log |
★★★★★ 逐请求记录 |
#Fields 行 |
文件头部 |
★★★★★ 解析的唯一依据,绝不能写死列索引 |
| 关键字段 |
sc-status/sc-substatus/sc-win32-status 三元组、time-taken、cs(User-Agent) |
★★★★★ |
| 自定义字段 |
#Fields 末尾多出的 x-forwarded-for 一列,文件名带 _x |
★★★★ 穿透 CDN 的取证手段 |
★ 站点 ID 的三个要点:① 日志目录 W3SVC2 里的 2 就是 id="2",与站点名无关;② ID 由创建顺序决定,站点删过会留下 ID 空洞(1、3 存在而 2 不存在),这本身就是站点被删过的线索,且空洞站点的历史日志常常仍留在磁盘上;③** logFile/directory 可配到任意路径**,很多生产环境改到数据盘。不读配置就按 C:\inetpub\logs 找,会完全找不到日志。
能回答什么:IIS 版本与配置格式(MetaBase.xml 还是 applicationHost.config);哪个站点 ID 对应哪个域名与物理路径;日志实际记录了哪些字段、按什么顺序;某次请求的真实状态是"未找到"还是"MIME 拒绝"(三元组才能区分)。
自定义字段是否存在——这一条决定你能否穿透 CDN 取到真实客户端 IP。某个 W3SVC<n> 目录里是否藏着已删站点的历史日志,也在答案里。
不能回答什么:请求体与响应体内容(W3C 不记录);某次请求是否真的执行了后端代码(200 也可能是错误页);time-taken 里有多少是网络耗时(它含在内)。
IIS 4/5 的二进制 MetaBase.bin 里的历史配置也拿不到——不含有效数据,别在这上面浪费时间。至于 sc-substatus 之外的静默失败路径,日志根本没记。
读者前提:需要 HTTP 请求/响应结构、XML 基础、Windows 服务与配置路径。
时区是本篇最容易被忽略的硬前提:W3C 的 date/time 是 UTC,事件日志是本地时间。UTC+8 环境直接比对会整体错 8 小时,足以把"先登录后访问"颠倒过来,详见 00 篇的 4.2 时间基准。
建议严格按 3.1 第 1 步 → 3.2 第 2 步 → 3.3 第 3 步 → 3.4 第 4 步 的顺序执行。跳步会让后面所有统计静默失真。
二、核心原理
2.1 IIS 版本差异:配置体系的两代更替
这是 IIS 取证第一个必须确认的事实——配置在哪、什么格式,完全取决于版本。
| 版本 |
配置文件 |
格式 |
站点标识 |
| IIS 6.0(Win2K3) |
%windir%\system32\inetsrv\MetaBase.xml + MBSchema.xml |
XML 明文 |
数字 Site ID |
| IIS 4/5.0 |
%windir%\system32\inetsrv\MetaBase.bin |
二进制(难解析) |
数字 Site ID |
| IIS 7.0+(Win2008+) |
%windir%\system32\inetsrv\config\applicationHost.config |
XML 明文 |
字符串 W3SVC<n> |
| IIS 7.0+ 缓存 |
.../config\apphost.config |
二进制缓存 |
— |
三条关键推论:
- IIS 6.0 已经是 XML 明文(IIS 4/5 的
MetaBase.bin 才是二进制)。国内仍有大量 Win2K3 + IIS 6 老系统,见到 MetaBase.xml 不要惊讶。
- IIS 6.0 目录里可能还残留
MetaBase.bin,但它不含有效配置——那是向后兼容占位文件。"备份 MetaBase.bin 即可"是错的,必须备份整个 inetsrv 目录。
- IIS 7+ 引入自动配置备份:每次改配置,旧版本存为
applicationHost.config.bak。取证必须一并提取。
两代配置读站点映射的关键属性完全不同。
IIS 6 的站点是一个 locsection,节点名 W3SVC/1 里的 1 就是 Site ID,绑定、目录、日志路径分别存在 ServerBindings / Root / LogFileDirectory 三个 property 里。
IIS 7+ 则是一个 site 元素(注意 logFormat 是 logFile 的属性而非子元素):
<site name="digiforensics-site" id="2" serverAutoStart="true">
<application path="/">
<virtualDirectory path="/" physicalPath="C:\inetpub\wwwroot\digiforensics-site" />
</application>
<binding protocol="https" bindingInformation="*:443:digiforensics.example.com" />
<logFile logFormat="W3C" enabled="true">
<directory>D:\IISLogs\W3SVC2</directory>
</logFile>
</site>
对应的 IIS 6 写法是 <locsection name="W3SVC/1"> 内含 <property name="Root" type="string" value="C:\inetpub\wwwroot\digiforensics-site" /> 等(完整解析见 3.3)。
这三种属性的名字两代完全不通用。写解析脚本时不要试图用同一段逻辑兼容。
★ 站点 ID 的三个要点:
- 日志目录名
W3SVC2 里的 2 就是 id="2",与站点名无关——id="2" 完全可能叫 Finance。
- ID 由创建顺序决定,站点删过会留下 ID 空洞(1、3、4 存在而 2 不存在),这本身就是站点被删过的线索。
logFile/directory 可配到任意路径,很多生产环境改到数据盘。不读配置就按 C:\inetpub\logs 找,会完全找不到日志。
2.2 ★ W3C 日志的文件结构:先读 #Fields
这是本篇最核心、也最容易出错的知识点。
W3C 日志是 ASCII 文本,一行一条 HTTP 请求记录,文件开头有几行以 # 开头的指令行。
#Software:(软件版本,判断 IIS 版本)、#Version:(格式版本,通常 1.0)、#Date:(判断是否被轮转/重建)、#Remark:(任意注释,应忽略),以及最关键的** #Fields:(字段名称与顺序,解析日志的唯一依据)**。
#Software: Microsoft Internet Information Services 10.0
#Version: 1.0
#Date: 2024-11-14 00:00:00
#Fields: date time s-computername s-ip cs-method cs-uri-stem cs-uri-query s-port cs-username c-ip cs(User-Agent) cs(Referer) sc-status sc-substatus sc-win32-status time-taken cs-bytes cs-host
2024-11-14 03:12:07 DIGIFORENSICS-WEB 10.10.0.5 GET /uploads/up.aspx t=1 443 - 185.220.101.34 - - 200 0 0 1178 66 digiforensics.example.com
★ 第一条硬规则:任何解析器都必须先读 #Fields 行,绝不能写死列索引。 IIS 管理器里勾选"选择字段"就能改集合,管理员、运维脚本、模板站点都可能改过。
2.3 ★★ IIS 7+ 的默认字段顺序(本文核心)
IIS 7 及以上全新安装的 W3C 日志,默认选中字段的顺序是:
#Fields: date time s-computername s-ip cs-method cs-uri-stem cs-uri-query s-port
cs-username c-ip cs(User-Agent) cs(Referer)
sc-status sc-substatus sc-win32-status time-taken cs-bytes cs-host
细节一:cs-username 之后直接是 c-ip,中间没有 cs-domain。(… s-port cs-username c-ip cs(User-Agent) …)
很多资料(尤其从 IIS 6 时代文档转述来的)会让人以为 cs-username 后面是 cs-domain(客户端域名),然后才是 c-ip。
事实是:W3C 格式的字段列表里没有 cs-domain 这个标准字段。
按 cs-username → cs-domain → c-ip 去解析,会把 c-ip 的值错认成域名,并且后续所有字段整体错位一列。
若日志里真出现 cs-domain,那一定是管理员配置的自定义字段,不是标准字段。
细节二:字段前缀的四段式含义。 s- = server(服务器侧)、c- = client(客户端侧)、cs- = client sent(客户端发给服务器的)、sc- = sent to client(服务器返回给客户端的)。括号形式 cs(User-Agent)、cs(Referer)、cs(Cookie) 同样遵循此规则。
细节三:IIS 6 的字段顺序不同。 IIS 6.0 默认字段与 IIS 7+ 基本一致但结尾无 cs-host;而在最简配置下(只勾必需项)会短得多:
#Fields: date time cs-username c-ip cs-method cs-uri-stem cs-uri-query sc-status sc-substatus sc-win32-status
绝不能跨版本套用解析器:IIS 6 最简配置里 sc-status 在索引 7,IIS 7+ 默认配置里在索引 14。同一解析器在两种日志上结果完全不同,且不报错。
2.4 关键字段的判读要点
| 字段 |
判读要点 |
date / time |
★ 是 UTC,不是服务器本地时间。国内服务器常为 UTC+8,与事件日志比对会整体错 8 小时 |
cs-uri-stem / cs-uri-query |
请求路径(不含查询串,部分版本把 / 记成 \)/ 查询串(不带前导 ?,无则 -) |
cs-username |
认证用户,匿名为 -。出现非常规账号是提权信号 |
c-ip |
客户端 IP。在 CDN/反代后面这是代理 IP,不是真实来源 |
cs(User-Agent) |
空格被替换为 +,非打印字符也替换为 +。还原时 + → 空格 |
sc-status |
HTTP 状态码(详见 03) |
sc-substatus |
IIS 细分子状态。404.0=资源不存在,404.1=站点不存在,404.3=MIME 类型限制 |
sc-win32-status |
Windows 系统错误码,0 = 成功。2=找不到文件、3=找不到路径、5=拒绝访问 |
time-taken |
★ 单位是毫秒,从收到第一个字节到最后发送完,含网络耗时 |
cs-bytes / sc-bytes |
收/发字节数,默认不勾选,未开启时不存在这两列 |
time-taken 的两个坑:单位是毫秒不是秒(1841 = 1.8 秒);它包含网络耗时——大文件下载时反映的是客户端网速。判断应用性能应只看动态请求(.aspx/.ashx/API)。
2.5 自定义字段与 _x 后缀
管理员可添加自定义字段(Custom Field),从请求头/响应头/服务器变量取值,最常见用途是记录 X-Forwarded-For 以穿透 CDN。
配了之后 #Fields 末尾会多出 x-forwarded-for 一列。
| 事项 |
说明 |
| 文件名会变 |
配置自定义字段后,IIS 写出的新日志文件名加 _x 后缀(u_ex241114_x.log),老文件保持原样 |
| 追加在末尾 |
自定义字段追加在 #Fields 列表末尾,因此不会挤掉 cs-username→c-ip 的相对顺序 |
| 不配置就不存在 |
没配 cs(Cookie)、cs-host、cs-bytes 时,这些字段根本不在 #Fields 里 |
| ★ 同一文件内可能变更 |
IIS 日志有缓冲写入(flushByEntryCountW3CLog 按 64K 条目批量刷新)。若管理员当天改了配置,同一文件里会出现两行 #Fields,前后段顺序不同 |
★ 解析器必须每遇到一次 #Fields 就重新读取,不能只在文件开头读一次。 这正是本文解析器的核心设计。
2.6 日志轮转与 W3SVC<n> 目录
默认按天轮转,文件名 u_exYYMMDD.log(YYMMDD = 两位年 + 月 + 日):
| 项目 |
说明 |
| 目录名 |
C:\inetpub\logs\LogFiles\W3SVC<站点ID>\(可配到别处) |
| IIS 7+ 文件名 |
u_ex241114.log(配自定义字段时为 u_ex241114_x.log) |
| IIS 6 文件名 |
ex241114.log(无 u_ 前缀) |
| 轮转粒度 |
Daily(默认)/ Hourly / Weekly / Monthly / MaxSize(按大小,默认 20 MB) |
| IIS 7.5+ 旧格式 |
单个滚动文件 u_ex.log(不按天分);部分部署另有 .gz 归档 |
| IIS 6 默认路径 |
%systemroot%\system32\LogFiles\W3SVC<n>\ |
★ 取证必须用通配符 *.log* 而非 u_ex*.log:会漏掉 IIS 6 的 ex*.log、带 _x 的自定义字段文件、.gz 归档,以及被改到非默认目录的日志。
W3C 日志只覆盖 HTTP 请求这一条线。
站点被改配置、被停用、服务进程崩溃这些动作落在 evtx 里。
按事件 ID 抽取并与访问日志合并成同一条时间线的方法见事件日志深度分析。
三、操作步骤
3.1 第 1 步:确认 IIS 版本与配置格式
Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\InetStp\Components' | Select-Object MajorVersion, MinorVersion
Get-ChildItem C:\Windows\System32\inetsrv\config\applicationHost.config*,
C:\Windows\System32\inetsrv\MetaBase.* | Select-Object FullName, Length, LastWriteTimeUtc
# 最可靠:直接从日志的 #Software 行确认(升级后残留旧日志时以它为准)
Get-Content D:\IISLogs\W3SVC2\u_ex241114.log -TotalCount 3
验证:#Software 版本与注册表 MajorVersion 不一致时(升级残留常见),以日志自身版本为准——解析行为由产生它的版本决定。
3.2 第 2 步:读 #Fields,建立字段映射
这一步是全部后续分析的前提。
# 2.1 每个日志文件的 #Fields(★ 同一文件可能有多行,配置变更会产生新的)
Get-ChildItem D:\IISLogs\W3SVC2\*.log | ForEach-Object {
Select-String -Path $_.FullName -Pattern '^#Fields:' |
Select-Object @{n='File';e={$_.Filename}}, LineNumber, Line } | Format-List
# 2.2 人工核对列数与关键字段索引
$fields = ((Select-String -Path $log -Pattern '^#Fields:').Line -replace '^#Fields:\s*','') -split '\s+'
$data = (Get-Content $log | Where-Object { $_ -notmatch '^#' } | Select-Object -First 1)
"#Fields=$($fields.Count) 数据行=$(($data -split '\s+').Count) " +
"cs-username=$([array]::IndexOf($fields,'cs-username')) c-ip=$([array]::IndexOf($fields,'c-ip'))"
验证:数据列数必须等于 #Fields 列数。不等就是两条线索之一——日志被截断(见 04),或该行正好在字段配置变更的边界上。
3.3 第 3 步:还原站点拓扑