1. 为什么IIS的错误页会成为信息泄露的入口
很多做运维的朋友对IIS的感情很复杂——部署快、图形界面友好、和Windows生态贴合紧密,但默认配置里藏着不少"话多"的毛病。最典型的就是HTTP 403、404、500这类错误响应:默认情况下,IIS会返回一个详细的错误页面,里面可能包含物理路径、模块名称、堆栈片段、服务器版本号,甚至ASP.NET的详细异常信息。这些内容对攻击者来说就是免费的侦察情报。
我见过不止一次这样的情况:某公司内网系统上线后,安全扫描报告里赫然写着"服务器信息泄露",点开一看,就是404页面把D:\WebApp\Site\bin\...这样的绝对路径原样吐了出来。攻击者拿到路径结构,就能推测目录布局、框架版本,进而有针对性地构造请求。所以"屏蔽敏感信息"这件事,本质上不是把错误页做得好看,而是把错误响应收敛成最小信息集——只告诉客户端"出错了",不告诉它"错在哪、服务器长什么样"。
这篇内容面向的是有一定IIS操作基础、需要做安全加固的运维和开发人员。我会从错误页的生成机制讲起,把自定义错误页、httpErrors与customErrors的区别、响应头清理、状态码统一处理这几个关键环节拆开讲透,最后给出一套可以直接照着做的配置方案和排查清单。新手也能跟着操作,老手可以重点看配置取舍和踩坑部分。
先明确一个概念:IIS里"错误页"其实分好几层。静态文件请求走的是HTTP层面的错误处理,ASP.NET应用走的是应用层的异常处理,两者配置入口不同、优先级也不同。很多人只改了其中一处,测试时以为生效了,换个请求方式又漏了——这是最常见的翻车点。下面我们一层层剥开。
2. 错误响应的三层结构与信息泄露来源拆解
2.1 IIS默认错误页到底吐出了什么
先做个实验性质的观察(在测试环境里做,别在生产上直接试)。请求一个不存在的路径,默认响应体大致长这样:一个大标题"HTTP 错误 404.0 - Not Found",下面跟着"您要找的资源已被删除、已更改名称或暂时不可用",再往下是"详细错误信息"模块,列出模块名、通知、处理程序、错误代码,最底下是"请求的URL""物理路径""登录方法""登录用户"。物理路径这一项就是重灾区,它直接把服务器磁盘结构暴露了。
403的情况类似,会区分403.1(禁止执行访问)、403.2(禁止读取访问)、403.3(禁止写访问)等子状态码,每个子码对应不同的详细说明。这些子码本身不算敏感,但配合详细错误信息里的路径和模块名,拼图就完整了。500错误更危险,如果customErrors没关,ASP.NET会把异常堆栈、源码行号、甚至连接字符串片段渲染出来。
所以屏蔽敏感信息的第一原则是:对外只保留状态码和一句通用提示,其余全部砍掉。这不是"美化",是"减面"。
2.2 静态错误与动态错误的分工
IIS处理错误响应有两条路径,理解这个分工是配置不翻车的前提。
第一条是HTTP层,由web.config里的<httpErrors>节点控制。它管的是IIS自身产生的错误,比如文件不存在、目录禁止浏览、请求方法不允许、URL过长等。这个节点有existingResponse属性,取值Auto、Replace、PassThrough,决定了当后端(比如ASP.NET)已经生成了响应时,IIS要不要用自己的错误页覆盖它。
第二条是应用层,由<system.web>下的<customErrors>(针对ASP.NET)或<system.webServer>下的相关配置控制。它管的是托管代码抛出的异常,比如空引用、数据库连接失败。customErrors的mode属性有On、Off、RemoteOnly三个值,RemoteOnly表示本地访问看详细错误、远程访问看自定义页——这个设计本意是方便调试,但如果服务器被从"本地"角度利用(比如SSRF打内网回环),RemoteOnly就成了漏洞。
两条路径的优先级关系是:请求先到HTTP层,如果URL能映射到处理程序,就交给应用层;应用层出错后生成响应,HTTP层再根据existingResponse决定是否覆盖。很多人只配了customErrors,结果静态文件404还是走IIS默认页;或者只配了httpErrors,结果ASP.NET异常还是把堆栈吐出来。两边都要配,且要保证覆盖关系正确,这是核心。
2.3 响应头里的"隐形泄露"
错误页正文之外,响应头也是信息源。默认情况下IIS会带Server: Microsoft-IIS/10.0,ASP.NET会带X-AspNet-Version、X-Powered-By。这些头在正常响应里也有,但在错误响应里配合状态码,能帮攻击者快速判断技术栈和版本,进而匹配已知问题。
清理响应头有两个层面:一是移除X-Powered-By这类应用层头,可以在web.config的<httpProtocol><customHeaders>里用<remove>;二是Server头,IIS原生不提供直接移除的配置项,需要通过URL重写模块的出站规则或者注册表调整来压制。这部分我在第4节会给具体做法。需要强调的是,隐藏版本号不是万能药,它只是抬高侦察成本,真正的防线还是错误响应本身不携带业务和路径信息。
3. 自定义错误页的配置实操与取舍
3.1 httpErrors节点的完整写法与参数含义
先看HTTP层的配置。在站点根目录的web.config里,<system.webServer>下加<httpErrors>节点。一个收敛型的配置大概是这样:
<system.webServer> <httpErrors errorMode="Custom" existingResponse="Replace" defaultPath="/errors/generic.html" defaultResponseMode="File"> <remove statusCode="403" subStatusCode="-1" /> <error statusCode="403" subStatusCode="-1" path="/errors/403.html" responseMode="File" /> <remove statusCode="404" subStatusCode="-1" /> <error statusCode="404" subStatusCode="-1" path="/errors/404.html" responseMode="File" /> <remove statusCode="500" subStatusCode="-1" /> <error statusCode="500" subStatusCode="-1" path="/errors/500.html" responseMode="File" /> </httpErrors> </system.webServer>逐项解释。errorMode="Custom"表示用自定义页替代详细错误;如果设成DetailedLocalOnly,本地请求仍会看到详细页,远程看不到——测试阶段可以用,上线必须改Custom。existingResponse="Replace"是关键,它强制IIS用自己的错误页覆盖后端已经生成的响应,这样ASP.NET吐出的堆栈也会被替换掉。defaultPath和defaultResponseMode是兜底,没显式列出的状态码走这里。
每个<error>里的subStatusCode="-1"表示匹配该状态码的所有子码,比如403.1到403.9全部命中同一条规则。responseMode="File"表示path指向一个静态文件,IIS直接读文件返回,不经过任何处理程序——这一点很重要,静态文件响应快、不触发应用逻辑、不会二次出错。如果写成responseMode="ExecuteURL",path会被当作URL重新走一遍管道,虽然灵活但增加了出错面,一般不建议在错误页上用。
注意:
<remove>标签不能省。IIS的httpErrors有继承机制,父级(比如applicationHost.config或上层目录)可能已经定义了同状态码的规则,不先移除会报"已添加相同键的项"配置错误。这个报错在IIS管理器里表现为站点启动失败,事件日志里能看到500.19,排查时优先怀疑重复定义。
3.2 customErrors与httpErrors的配合关系
应用层的<customErrors>写在<system.web>下:
<system.web> <customErrors mode="On" defaultRedirect="/errors/generic.html" redirectMode="ResponseRewrite"> <error statusCode="404" redirect="/errors/404.html" /> <error statusCode="500" redirect="/errors/500.html" /> </customErrors> </system.web>mode="On"表示无论本地远程都显示自定义页,这是生产环境的正确取值。redirectMode有两个值:ResponseRedirect会返回302再跳转,地址栏会变,且原始状态码丢失(变成302再200),对SEO和API客户端都不友好;ResponseRewrite是内部重写,对外仍返回原始状态码(404还是404),只是响应体换成了自定义页。做API或需要保留状态码语义的场景,必须用ResponseRewrite。
这里有个容易混淆的点:customErrors的redirect和httpErrors的path同时存在时,谁生效?答案是看existingResponse。如果httpErrors设了Replace,那么无论应用层返回什么,最终都会被HTTP层的错误页覆盖。所以实践中我倾向于以httpErrors为主、customErrors设为On做双保险,两者指向同一套静态错误页,避免出现"应用层换了页、HTTP层又换回去"的混乱。
3.3 错误页文件本身的设计原则
自定义错误页不是随便放个"出错了"就完事。从信息安全角度,它要满足几个约束:不含任何服务器路径、模块名、版本号、堆栈、内部IP、数据库信息;不含指向后台管理入口的链接(避免给攻击者指路);状态码与页面内容一致(404页就返回404,别返回200,否则监控和爬虫都会误判)。
我的做法是准备一套极简静态页,每个文件控制在几KB以内,纯HTML加内联样式,不引用外部JS和CSS(避免错误场景下静态资源也404导致页面残缺)。内容就一句话加一个返回首页的链接,链接用相对路径。比如404页:
<!DOCTYPE html> <html lang="zh-CN"> <head><meta charset="utf-8"><title>页面不存在</title></head> <body> <h1>页面不存在</h1> <p>您访问的地址可能已变更或输入有误。</p> <p><a href="/">返回首页</a></p> </body> </html>提示:错误页目录建议放在站点根下的独立文件夹(如
/errors/),并在web.config里对该目录关闭目录浏览、禁止执行脚本,防止有人通过错误页路径做进一步探测。同时确保这个目录对匿名用户可读,否则错误页本身会403,形成"错误页报错"的循环。
4. 响应头清理与状态码统一处理
4.1 移除版本号类响应头的可落地方案
X-Powered-By和X-AspNet-Version的移除相对直接。在web.config里:
<system.webServer> <httpProtocol> <customHeaders> <remove name="X-Powered-By" /> <remove name="X-AspNet-Version" /> <remove name="X-AspNetMvc-Version" /> </customHeaders> </httpProtocol> </system.webServer>X-AspNetMvc-Version需要在Global.asax的Application_Start里加一行MvcHandler.DisableMvcResponseHeader = true;,因为它是MVC框架在运行时写入的,配置节点管不到。
Server头麻烦一些。IIS没有原生开关,常见做法是用URL重写模块的出站规则把该头清空:
<rewrite> <outboundRules> <rule name="RemoveServerHeader"> <match serverVariable="RESPONSE_Server" pattern=".+" /> <action type="Rewrite" value="" /> </rule> </outboundRules> </rewrite>这需要服务器安装URL重写模块。另一种做法是调整注册表项让IIS不追加版本信息,但涉及服务器级配置,改动前要评估对同机其他站点的影响。我的建议是:优先用出站规则,作用域限定在站点级,影响面可控;注册表方案留给有统一运维规范的场景。
4.2 状态码语义不能丢
屏蔽敏感信息不等于把所有错误都变成200或404。有些团队图省事,把所有异常统一返回一个"系统繁忙"页且状态码设200,结果监控系统完全失灵——接口挂了但健康检查全绿。正确做法是保留原始状态码语义,只替换响应体。403就是403,404就是404,500就是500,客户端和监控靠状态码做判断,响应体只是给人看的。
对于API类接口,还要考虑响应格式。如果站点同时提供HTML页面和JSON接口,错误页最好按Accept头分流:浏览器请求返回HTML错误页,Accept: application/json的请求返回{"code":404,"message":"资源不存在"}这样的精简JSON。这个分流可以用URL重写规则配合条件判断实现,也可以在应用层统一异常过滤器里做。分流的意义在于:给浏览器的页面可以友好,给程序的响应必须精简且结构稳定,两者都不该带内部细节。
4.3 子状态码的收敛策略
前面提到subStatusCode="-1"可以匹配所有子码。但有些场景需要区别对待,比如403.14(目录浏览被拒)和403.1(执行权限被拒),前者可能是正常访问目录导致,后者可能是配置问题。如果全部收敛到同一个页面,排查时缺少线索。
折中方案是:对外统一页面,对内保留日志。也就是错误页不区分,但开启IIS的失败请求跟踪(Failed Request Tracing),把详细的子状态码、模块、耗时记录到本地日志里,供运维排查。这样既不让外部看到细节,又不丢失内部诊断能力。失败请求跟踪的规则在IIS管理器的"失败请求跟踪规则"里配置,建议只对500和403开启,避免日志量过大。
5. 常见问题排查与避坑清单
5.1 配置改了不生效的典型原因
这是被问得最多的问题。按经验排序,原因大致有这么几类:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 静态404仍是默认页 | 只配了customErrors,没配httpErrors | 检查站点根web.config是否有httpErrors节点 |
| 应用异常仍吐堆栈 | customErrors为Off或RemoteOnly | 改为On,并确认existingResponse=Replace |
| 配置报500.19 | httpErrors重复定义 | 补<remove>标签,检查父级继承 |
| 错误页本身404 | path指向的文件不存在或路径大小写不符 | 核对物理路径与URL映射 |
| 状态码变成200 | redirectMode用了ResponseRedirect或页面被当普通页返回 | 改用ResponseRewrite,确认错误页由IIS直接返回 |
| 本地正常远程异常 | errorMode为DetailedLocalOnly | 上线前改为Custom |
还有一类隐蔽问题:多个web.config层级冲突。站点根、子目录、applicationHost.config都可能定义错误页,子目录的配置会覆盖根目录。如果某个子应用有自己的web.config且没配httpErrors,它可能继承到不一致的规则。排查时用IIS管理器的"配置编辑器"查看生效配置,比逐个文件翻找高效得多。
5.2 错误页与URL重写的相互干扰
站点如果用了URL重写做伪静态或HTTPS跳转,错误页路径可能被重写规则拦截。比如规则把所有非文件请求重写到index.aspx,那么/errors/404.html虽然存在,但请求进来先被规则改写,导致错误页返回的其实是首页内容,状态码也乱了。
解决办法是在重写规则里加排除条件,把/errors/目录排除在外:
<rule name="SPARewrite" stopProcessing="true"> <match url=".*" /> <conditions logicalGrouping="MatchAll"> <add input="{REQUEST_URI}" pattern="^/errors/" negate="true" /> <add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" /> </conditions> <action type="Rewrite" url="/index.html" /> </rule>这个坑我在两个项目里都踩过,表现是"错误页配置明明对,但访问不存在路径返回的是首页且状态码200",查了半天才发现是重写规则先动手了。规则顺序和排除条件,永远要在配错误页时一起检查。
5.3 实操心得与独家建议
分享几条文档里不常写、但实际很管用的经验。
第一,先在测试站点把errorMode设为DetailedLocalOnly做对照测试。用本地请求看详细页确认错误确实产生,再用远程请求看自定义页确认替换生效,两边对比才能证明配置链路完整。直接上Custom容易"看起来生效了但其实是另一个环节兜住的"。
第二,错误页要纳入发布流程做回归。每次发版后跑一遍固定用例:请求不存在的静态文件、请求不存在的接口、触发一个已知异常、请求禁止访问的目录,逐个核对状态码和响应体。我见过发版时误删/errors/目录导致全站错误页404的事故,加个冒烟用例就能避免。
第三,日志与展示分离。对外收敛信息,对内要留全量。IIS日志默认记录状态码和子状态码,配合失败请求跟踪,排查时能还原现场。千万别为了"干净"把日志也精简了,那是自断后路。
第四,注意错误页的缓存。错误页如果被CDN或浏览器缓存,问题修复后用户可能还看到旧页面。在错误页响应里加Cache-Control: no-store,通过httpProtocol的customHeaders对/errors/路径设置,避免缓存污染。
第五,API与页面分流要早做。如果站点后期才加API,错误页分流往往要返工。前期就在统一异常处理里按Accept或路径前缀分流,后期扩展省事得多。
6. 一套可直接复用的收敛型配置模板
把前面的内容整合成一份站点根web.config的骨架,覆盖错误页、响应头、重写排除三块。实际使用时按站点情况调整路径和状态码列表。
<?xml version="1.0" encoding="UTF-8"?> <configuration> <system.web> <customErrors mode="On" defaultRedirect="/errors/generic.html" redirectMode="ResponseRewrite"> <error statusCode="403" redirect="/errors/403.html" /> <error statusCode="404" redirect="/errors/404.html" /> <error statusCode="500" redirect="/errors/500.html" /> </customErrors> <httpRuntime enableVersionHeader="false" /> </system.web> <system.webServer> <httpErrors errorMode="Custom" existingResponse="Replace" defaultPath="/errors/generic.html" defaultResponseMode="File"> <remove statusCode="403" subStatusCode="-1" /> <error statusCode="403" subStatusCode="-1" path="/errors/403.html" responseMode="File" /> <remove statusCode="404" subStatusCode="-1" /> <error statusCode="404" subStatusCode="-1" path="/errors/404.html" responseMode="File" /> <remove statusCode="500" subStatusCode="-1" /> <error statusCode="500" subStatusCode="-1" path="/errors/500.html" responseMode="File" /> </httpErrors> <httpProtocol> <customHeaders> <remove name="X-Powered-By" /> <remove name="X-AspNet-Version" /> <remove name="X-AspNetMvc-Version" /> <add name="Cache-Control" value="no-store" /> </customHeaders> </httpProtocol> <rewrite> <outboundRules> <rule name="RemoveServerHeader"> <match serverVariable="RESPONSE_Server" pattern=".+" /> <action type="Rewrite" value="" /> </rule> </outboundRules> </rewrite> </system.webServer> </configuration>这份模板有几个使用前提要说明。<rewrite>节点要求服务器装了URL重写模块,没装的话删掉该节点,Server头另行处理。Cache-Control加在全局会影响正常页面的缓存策略,如果站点有需要缓存的静态资源,应该把这条移到/errors/目录自己的web.config里,用<location>限定作用域。httpRuntime enableVersionHeader="false"是关掉ASP.NET版本头的另一道保险,和customHeaders里的remove互为补充。
配置完成后,按第5节的用例逐项验证。重点看三件事:状态码是否保留原值、响应体是否只剩通用提示、响应头里是否还有版本信息。三项都过,这套收敛就算落地了。
最后说个我自己的体会:错误页加固这件事,技术难度不高,难在"覆盖完整"和"长期保持"。配置一次容易,难的是每次发版、每次加子应用、每次调重写规则时都不破坏它。所以我的习惯是把错误页用例写进发布检查单,和单元测试一样对待——安全配置不是一次性动作,是持续维护的约束。这套东西跑顺之后,安全扫描里那几条"信息泄露"基本就消停了,剩下的精力可以放到更值得投入的地方。