☰
从波浪号~1到资产清单:IIS短文件名扫描与8.3短名枚举实战
2026/11/2 11:33:38 网站建设 项目流程

简介:IIS_shortname_Scanner.rar 是一份面向 Web 安全初学者、渗透测试人员及运维人员的 IIS 短文件名泄露检测工具包,主要用于验证目标站点是否存在 IIS 8.3 短文件名枚举风险,并配套提供了 Python 2.7 64 位环境包的下载入口。包内共 32 个文件,以 class、java、xml、md、py、jar 及脚本类文件为主,兼顾 Java 与 Python 两套实现,既有可直接运行的扫描程序,也保留源码、配置与构建说明,压缩包约 87KB,体积轻便、便于在本地快速部署和二次研究。现有 250 人学习下载,适合在授权测试环境中对照使用:读者可借助 Java 版快速执行检测,或通过 Python 脚本理解短文件名探测逻辑,并结合说明文档完成环境准备、参数调整与结果核验,为漏洞排查和加固验证提供可复用的工具与排错参考。

1. 一个波浪号暴露整份目录:IIS 短文件名扫描到底在扫什么

iis短文件名扫描不是什么新手法,但每次做内网资产梳理,它仍然是我第一批运行的检查项。一个开着 8.3 别名的 IIS 站点,请求里带上~1,就可能把没写进 robots.txt、首页也没有挂链接的备份文件、上传目录、测试页面一个个问出来——攻击面图上本来空白的一块,忽然有了轮廓。这个扫描包干的就是这件事:给它一个 URL 或一串 URL,它按 6 位前缀、波浪号编号、扩展名逐段试探,把看不见的名字还原成字符串。适合两类人:做授权测试、需要快速摸清目录结构的从业者;以及自己维护 IIS、想确认短名别名到底关没关的运维。前提只有一个——目标是你自己的,或者你手里有书面授权。

2. 8.3 短名是怎么生成的:波浪号后面的数字不是随机的

想把扫描结果读明白,得先知道那些AAAAAA~1是怎么来的。很多人拿到输出第一反应是「这堆乱码是什么」,实际上每一位都有生成规则,理解规则之后,你能从结果里反推出目录的创建顺序、扩展名长度,甚至判断某个命中是不是误报。

2.1 长名截断成 NAME~1.EXT 的推导过程

NTFS 为了兼容早期只认 8.3 格式的程序,会为不符合 8.3 规范的长文件名额外分配一个别名。规则大致是这样:取长文件名去掉空格和非法字符后的前 6 个有效字符作为主名,加一个~,再加序号(从 1 开始),扩展名取最后一段的前 3 个字符。举几个常见例子:

长名生成的短名说明
BackupFilesBACKUP~1无扩展名,主名取前 6 位
database_2023.bakDATABA~1.BAK主名截断,扩展名只留 3 位
web.config.bakWEBCON~1.BAK中间的点被吃掉,扩展名取最后一段
uploadupload本身已符合 8.3,不生成别名

关键在序号:~后面的数字不是哈希,也不是随机值,而是同一目录内「前缀冲突」的排队号。目录里如果已经有一个BACKUP~1,新建的同前缀文件就是BACKUP~2,以此类推。这意味着两点:第一,序号通常很小,1 到 9 覆盖了绝大多数场景,扫描器一般枚举到 9 就够;第二,序号反映的是创建先后,不是字母序,所以你不能靠~1、~2推断文件的排列。

非 ASCII 文件名会让这套规则退化。中文名每个字符占两个字节,截断后前缀可能只保留两三个汉字,扫描器用 ASCII 字符集去猜就基本命中不了。这也是为什么同一个站点,英文命名习惯的目录往往能扫出一大片,中文命名的目录却干干净净——不是没文件,是猜不出来。

2.2 哪些目录天生就没有别名

这条比规则本身更重要,直接决定你扫描结果的解读方式。有几种情况,文件明明存在,短名却压根不存在:

站点根目录通常不生成 8.3 别名。IIS 的根路径对应的是卷上的一个目录,但通过 Web 根访问时,很多实现不会为根本身分配别名,所以扫描必须从子路径开始。我见过有人直接把 URL 填成http://host/,跑完一片空白,就断定目标不存在短名问题——实际是起点选错了,换成http://host/upload/立刻有反应。

本身就是 8.3 格式的名字不会再生成别名。img、css、js、a.aspx、b.aspx这类名字,天生合规,NTFS 不会为它额外分配短名,扫描器自然问不出来。所以扫描结果里缺失的项,不代表文件不存在,只是「这个名字不需要别名」。

8dot3 生成开关可以被关闭。卷级别可以关掉短名生成,关掉之后新建的文件就不再产生别名。但注意,关闭不影响历史别名——之前已经生成的~1依然留在目录索引里,依然可以被问出来。这是运维侧最容易误判的一点:以为关了就干净了,实际只是不再新增。

系统目录自带一堆别名。_vti_开头的前端扩展目录、bin、App_Data、aspnet_client这些,在很多 IIS 部署里默认存在,扫描结果里出现它们是常态,别当成战果往报告里写。

2.3 手工发两个请求,把机制验证清楚

在跑工具之前,我习惯先用最笨的方式确认机制是活的。原理不复杂:往路径里塞一个非法的通配字符*,IIS 在判定「这个 URL 不合法」之前,会先拿它去匹配 8.3 名。名字存在,就会走到「非法字符」那一支;名字不存在,直接 404。返回码的差异就是信号。

# 目标换成你自己的靶机或授权范围内的地址 HOST="http://10.0.0.5:8080" # 试探:是否存在以 backup 开头的 8.3 名 curl -s -o /dev/null -w "%{http_code}\n" "$HOST/backup*~1*/.aspx" # 对照:随便编一个大概率不存在的前缀 curl -s -o /dev/null -w "%{http_code}\n" "$HOST/zzzzzz*~1*/.aspx" # 顺手确认服务器类型和版本特征 curl -sI "$HOST/" | grep -Ei "server|x-powered-by|x-aspnet-version"

-o /dev/null丢掉响应体,-w "%{http_code}"只打印状态码,-s静默进度条。三条命令分别回答三个问题:短名机制是否生效、对照组是否确实返回 404、目标是不是 IIS 以及大致版本。

读返回码要有点耐心,不同版本的 IIS 表现不完全一致。常见对照如下:

返回码含义备注
400短名存在通配字符被拒之前先完成了名字解析
404短名不存在最常见,也是主要判据
403存在但无权访问同样是命中信号,别当失败丢掉
200名字存在且请求被当真实路径处理少见,要结合响应体长度判断
500解析异常多半是 URL 编码或路径拼接写错了

提示:手工验证只发两三个请求,成本极低,但能省掉后面一大截「工具跑完没结果,到底是谁的问题」的排查时间。我现在的习惯是每换一个目标域名,先跑这三条。

3. Scanner 上手:跑通第一次枚举

确认机制活着,再谈工具。这一章按「环境 → 参数 → 跑一次 → 收结果」的顺序走,每一步都能直接照抄。

3.1 运行环境和前置检查

先在本地把靶场搭起来,别拿线上环境练手。Windows 上开 IIS、建一个测试站点、往目录里丢几个长名文件(比如backup_2023.zip、config_old.bak),确认 8dot3 没被关掉,这是最省事的验证环境。跑通之后再对着授权目标复现。

工具侧按包内入口分两种情况:如果是编译好的可执行程序,Windows 上直接双击或命令行带参数跑,注意某些安全软件会拦截这类行为,测试机上建议先加白名单;如果是脚本形态,先建虚拟环境再装依赖,别往全局 Python 里灌包。

# 脚本形态的通用起手式 python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install requests urllib3 # 先看帮助,把参数拼写和版本确认清楚 python scanner.py --help

虚拟环境的作用是隔离依赖版本。这类工具往往依赖requests的某个区间版本,全局环境里如果已经装了别的版本,很容易出现ImportError或者 TLS 握手异常,排查起来很费时间。--help一定要先跑,不同打包版本的参数拼写差异不小,下面表格里的写法只是常见形态。

注意:只在你自己的靶机或有书面授权的目标上执行。扫描行为会在对方 IIS 日志里留下成片的 400,特征非常明显,别抱侥幸心理。

3.2 参数怎么设:一张表看懂常用开关

参数(常见写法)作用建议值
-u/--url单个目标地址带 scheme 和端口,末尾不要漏斜杠
-f/--file批量目标清单一行一个,先去掉重复和不可达的
-t/--threads并发线程数8~16,内网可到 20,公网别超 10
--timeout单请求超时(秒)5,跨机房链路给到 8
-o/--output结果落盘统一.txt,方便后续 grep
-e/--ext扩展名字典先跑asp,aspx,zip,rar,bak,sql,config
-r/--recursive是否递归子目录授权范围内再开,容易放大请求量
--level前缀枚举深度默认 6,短名前缀超过 6 位没有意义

线程数这一项值得单独说。这类扫描不是带宽密集型,是延迟密集型,瓶颈在 RTT 不在吞吐。线程开太高,目标端的请求队列堆积,返回码开始出现 500 和超时,误判率立刻上去。我一般从 8 起步,观察一轮的状态码分布再往上加,看到 500 变多就立刻回调。

3.3 跑一次完整枚举

# 单目标:先跑最小集合,确认链路和判定都正常 python scanner.py -u "http://10.0.0.5:8080/" -t 8 --timeout 5 -e aspx,zip,bak -o run1.txt # 看到结果之后,再扩扩展名和深度 python scanner.py -u "http://10.0.0.5:8080/" -t 12 --timeout 5 \ -e aspx,asp,zip,rar,bak,sql,config,old -r --level 6 -o run2.txt # 批量目标:先去重、去不可达,再喂给工具 sort -u targets.txt | while read -r h; do code=$(curl -s -o /dev/null -m 3 -w "%{http_code}" "$h") [ "$code" != "000" ] && echo "$h" done > targets.alive.txt

第一轮的作用是校准,不是出成果。扩展名只放三个最常见,观察命中率、平均响应时间、有没有 500 冒出来。第二轮才放大范围,-r开递归的时候要盯着请求量,一个中等规模的站点递归下去几万个请求很正常。

最后的批量预处理容易被跳过,但很值。curl -w "%{http_code}"返回000表示连接失败,这类目标扔给扫描器只会白白消耗线程和时间,先过滤掉。sort -u去重是因为资产清单里同一个域名经常出现多行(不同端口、不同来源导出),重复跑毫无意义。

3.4 把输出接进后续流程

扫描结果一般长这样:http://host:8080/BACKUP~1.ZIP。直接交货没意义,得做二次加工——去掉系统目录、合并大小写重复项、按扩展名分类。

# 去掉常见的系统目录噪音,合并大小写重复 grep -viE "_vti_|aspnet_client|/bin/|/app_data/" run2.txt \ | tr 'A-Z' 'a-z' | sort -u > run2.clean.txt # 按扩展名分组,方便决定下一步验证哪个 awk -F. '{print $NF}' run2.clean.txt | sort | uniq -c | sort -rn # 只留高价值扩展名,进验证清单 grep -iE "\.(zip|rar|bak|sql|config|old)$" run2.clean.txt > verify.list

grep -viE里的-v是反选,-i忽略大小写,这几个系统目录在几乎所有 IIS 站点都会命中,留着只会稀释注意力。tr 'A-Z' 'a-z'是为合并大小写重复——同一个名字可能因为响应差异被记录成两种形式。awk -F. '{print $NF}'取最后一段当作扩展名,uniq -c数频次,一眼就能看出这个站点的备份习惯:如果.zip和.bak占了多数,说明运维有定时备份习惯,后面重点验证这几类。

4. 枚举算法拆解:一个字符一个字符地问出来

工具用起来简单,但结果准不准,全看判定逻辑和收敛策略。这一章把黑匣子拆开,讲清楚它每一轮在问什么、为什么这么问。

4.1 前缀枚举:为什么按位递进而不是一次全猜

短名前缀最多 6 位,字符集如果取 36 个(小写字母加数字),6 位的组合数是 36 的 6 次方,二十多亿。全量爆破不可能,所以所有实现都用同一个思路:逐位递进。先测 1 位,a*~1*、b*~1*……留下命中的;再在命中的前缀后面接一位,继续测;直到 6 位或者某一轮全灭。

import requests from concurrent.futures import ThreadPoolExecutor BASE = "http://10.0.0.5:8080/" CHARS = "abcdefghijklmnopqrstuvwxyz0123456789_-" HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "*/*"} def probe(prefix: str, timeout: float = 5.0) -> bool: # * 必须原样出现在路径里,这是判定成立的前提 url = f"{BASE}{prefix}*~1*/.aspx" try: r = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=False) except requests.RequestException: return False # 网络问题一律当未命中,避免污染结果 return r.status_code in (400, 403) def enum_prefix(length: int = 6, workers: int = 16): alive = [""] # 上一轮命中的前缀集合 for _ in range(length): cands = [p + c for p in alive for c in CHARS] with ThreadPoolExecutor(max_workers=workers) as pool: flags = list(pool.map(probe, cands)) alive = [c for c, ok in zip(cands, flags) if ok] print(f"depth={len(cands[0])} candidates={len(cands)} hits={len(alive)}") if not alive: # 全灭就停,省掉后面几万次无意义请求 break return alive

allow_redirects=False是刻意的:跟随跳转会把 301 隐藏掉,而 301 有时恰恰说明短名映射到了真实目录。ThreadPoolExecutor的workers取 16 是经验值,内网延迟 1~2ms 时可以拉到 24,跨机房就降到 8。probe里把异常直接判为未命中,看起来粗暴,但能避免网络抖动把结果带偏——代价是漏报,收益是结果可复现。真要提高召回,可以加一次重试,但别超过一次。

这个方案的请求量是这样:第一轮 38 次,之后每一轮的量取决于上一轮命中数。如果站点命中的前缀有 5 个,第二轮就是 190 次,六轮下来几千次请求,比全量爆破省了六个数量级。

4.2 编号和扩展名的判定顺序

前缀确定之后,接下来是两件事:验证~N的编号,以及猜扩展名。

编号验证相对确定,从~1测到~9就够。之所以上限是 9,是因为同一目录同前缀的文件很少超过十个;超过十个的目录,多半是上传目录,那类场景靠编号枚举反而不如直接看上传接口的命名规律。

扩展名要麻烦一些,判定模板换成PREFIX~1.EXT的形态。常见扩展名有上百个,全量测代价太大,实际做法是分两档:第一档是高频的十来个(aspx、asp、zip、rar、bak、sql、config、old、txt、xml),第二档才按站点技术栈补(.NET 站点补dll、pdb,PHP 站点补php、inc)。分档的依据是响应时间,第一档跑完如果命中率是零,第二档基本不用试,说明这个目录下要么没有别名,要么扩展名全在 3 位以内。

还有一种情况得单独处理:短名没有扩展名。有些备份文件就叫backup,没有后缀,这时候~1后面不跟点,形如/BACKUP~1。扫描器如果不测这种形态,就会漏掉一批。

4.3 并发、超时和重试的取舍

这三个参数是绑在一起的,单独调一个没意义。超时设 5 秒、并发设 8,意味着最坏情况下每秒只有 1.6 个请求能返回,一批六千个请求要跑一小时。超时降到 2 秒、并发拉到 20,同样的量十分钟跑完,但超时误判会上升——本来该 400 的请求,因为响应慢了被判成未命中。

我的做法是分段:先用timeout=3、workers=8跑一小批(比如两百个候选),统计超时率和 500 率。超时率低于 1%,就放宽到workers=16、timeout=5;超时率高于 5%,说明目标端扛不住或者链路有问题,先降并发再加超时。这个标定过程听着麻烦,实际跑两次就有数了,比盲目拉参数省时间。

重试要慎用。一次重试能把偶发丢包救回来,但也会把误判放大——如果目标端对非法字符的处理本身不一致(有些 WAF 会随机返回 400),重试反而会造出假命中。我一般只在ConnectionError上重试,状态码类的问题不重试,宁可漏。

4.4 误报抑制:几种会把 400 当成命中的情况

400 是主要判据,但不是所有 400 都是短名命中。以下几种情况会污染结果:

WAF 拦截。很多 WAF 看到路径里有*或~直接返回 400,和短名无关。识别方法是看响应体——WAF 的 400 通常带一个明显的拦截页面,长度远大于 IIS 的默认错误页。加了拦截特征之后,命中数会掉一截,但剩下的都是真的。

路径规范化。有的中间件会把*编码掉或者直接拒绝,返回 400 但和短名毫无关系。识别方法是拿一个绝对不可能存在的前缀做对照,如果对照也 400,说明这一路全是噪音,整个目标可以直接放弃。

URL 编码差异。%2A和*在不同版本上表现不同,扫描器如果做了编码转换,判定基准就变了。稳妥做法是固定用未编码的*,别自作聪明。

提示:正式输出之前,随机抽 5 个命中项手工复验一遍。命中率高不代表准,我吃过一次亏——报告里八十多条结果,手工一验,三分之一是 WAF 拦出来的假阳性。

5. 避坑与排查:五类翻车现场

下面这些是跑这类工具时最常撞上的,按「现象 → 原因 → 解决」记,遇到时直接对号入座。

5.1 判定类问题

现象:整站零命中,手工发请求却是 400。原因通常是扫描器请求的 URL 模板和你手工测的不一样——工具可能在*后面自动补了扩展名,或者把路径末尾的斜杠吃掉了,导致 IIS 走的是另一条解析分支。解决方法是抓一次工具的原始请求(--debug或者本地起个抓包看),逐字符和你手工那条对比,把模板对齐。这类问题我遇到三次,两次都是模板末尾少了斜杠。

现象:命中一大堆,全是_VTI_、BIN、ASPNET_CLIENT。原因不是误报,是这些系统目录确实有别名,而且前缀固定、必然命中。解决靠过滤,grep -viE一次性排掉,别指望工具自己识别——它不知道你的业务上下文,判断不了哪些目录值得关注。过滤规则建议固化成一个脚本,每次跑完自动执行。

现象:报告交上去被反馈「这个文件不存在」。原因是短名映射到了真实文件,但真实文件的权限、处理程序映射不一样,直接访问短名可能 404 或 403。短名存在只证明目录索引里有这条记录,不证明文件内容可读。解决办法是在报告里把「短名存在」和「文件可访问」分开写,别混成一件事,前者是信息泄露,后者才是文件泄露。

5.2 环境与流程类问题

现象:靶场上跑得好好的,换到真实目标全部超时。原因多半是出站限制或者目标前有负载均衡,扫描请求被分散到不同节点,有的节点响应快有的慢,超时设得紧就大面积失败。解决方法是先跑 20 个候选做标定,看 P95 响应时间,把超时设到 P95 的两倍以上,并发相应降下来。

现象:同一份清单跑两次,结果不一样。原因是并发下的时序问题——目标端在高压下返回码不稳定,同一路径这次 400 下次 404。解决办法是把并发降到 8 以下重跑一遍,取两次结果的交集作为可信集。交集之外的项单独列出来,标注「待复验」,别直接写进结论。

注意:扫描结论一旦进了正式报告,就要经得起复现。凡是跑两次结果不一致的项,宁可不写。

6. 进阶:把~1变成可核对的清单

拿到短名只是起点,能不能变成可核对的结论,靠的是后面这几步。

验证的第一步是拿短名反查真实名。请求http://host/UPLOAD~1/时留意响应:如果返回 301,Location 里往往带着真实目录名,这一步的收益极高——UPLOAD~1直接变成uploads_2023,比猜一百次字符都准。

# 拿短名反查真实名:重点看 Location 头和状态码 curl -sI "http://10.0.0.5:8080/UPLOAD~1/" | grep -Ei "^(HTTP|location)" # 短名直接访问文件,看是否 200 且长度合理 curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \ "http://10.0.0.5:8080/BACKUP~1.ZIP"

第二步是做交叉印证。短名枚举出来的扩展名,和站点技术栈对不上的要打问号:一个纯静态站点扫出.aspx,多半是历史遗留或者误判;扫描器报~1但编号跳到了~5,说明中间几个编号的文件已被删除,这个目录有过清理动作,值得单独看一眼。

第三步是把结果整理成能过复核的格式。我习惯用三列:短名、推断的真实名、验证状态。推断真实名靠三样东西——短名前缀 6 位、扩展名、以及同目录下其他文件的命名规律(比如都带日期后缀)。验证状态只有三个值:已确认(短名反查到了真实名)、部分确认(文件可访问但真实名未知)、未验证。

短名推断真实名验证状态
BACKUP~1.ZIPbackup_2023.zip已确认
UPLOAD~1uploads_old已确认
CONFIG~2.BAK—部分确认

最后说一个我踩过的坑。早期我特别喜欢追命中数量,一个站扫出两百条就很有成就感,报告里堆得满满当当。后来被反馈问了一次「这些路径哪些是真能访问的」,才发现两百条里能落地的不到二十条——大量的系统目录噪音、WAF 假阳性、以及早就被删掉只剩索引记录的空壳。从那以后我每次跑完都强制走一遍「过滤 → 抽样手工复验 → 只留可核对项」这三步,宁可结果清单短一点,也不要一份自己都不敢复现的报告。这套动作现在固定在脚本里,扫完自动执行。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询