2025 年 1 月 14 日,Fortinet 公开了影响 FortiOS、FortiProxy 有限几个版本的身份验证绕过漏洞 CVE-2024-55591 ,本文对此漏洞进行简要分析。
该漏洞已发现在野利用,建议受影响的用户及时更新系统版本。
基本信息 根据 Fortinet 发布的公告可知,这个漏洞存在于 Node.js 的 websocket 模块中。成功利用该漏洞可以获取系统的管理员权限并执行任意 CLI 命令,漏洞仅影响 FortiOS 7.0.x 以及 FortiProxy 7.0.x、7.2.x。
另外根据 IOC 信息可知,攻击者最终通过 jsconsole 登录到系统,jsconsole 就是 FortiOS web 后台提供的 CLI 接口。所以漏洞应该位于管理端口中,可能通过某种方法跳过了鉴权过程,可以直接访问到 web-cli 接口。
CVE-2022-40684 在进一步分析漏洞之前,有必要先分析一下 CVE-2022-40684。这是一个公布于 2022 年 10 月的,同样也是影响 FortiOS 管理端口的身份验证绕过漏洞。当前网络上已经有很多相关的复现分析文章,我们在这里只做简要总结。
首先要明确 FortiOS 管理端口的基本请求处理过程,在前面存在一个 Node.js 服务,后端运行的是 httpsd web 服务器,实现了一个类似负载均衡的结构,Node.js 接收请求会发送到后端某个 httpsd 进程中。
在 Node.js 和 httpsd 中各有一套身份验证逻辑,Node.js 的角色类似于 Nginx 代理,它在转发请求时,会在请求中附加一个 Forwarded 请求头,其中 for 和 by 字段表示转发该请求的代理服务器 IP 以及请求的来源 IP。在 httpsd 中,对请求进行鉴权时调用 fweb_authorize_all
函数,这个函数会解析 Forwarded 请求头,获取其中的 for 字段内容,并且将获取到的 IP 赋值给 Client-IP。
随后的鉴权流程在 api_access_check_for_trusted_access
函数中会检查 Client-IP 以及 User-Agent 的值,如果前者为 127.0.0.1,后者为 Node.js 或者 Report Runner,则会认为请求来自于本地,跳过后续鉴权流程,同时设置当前用户身份为 Local_Process_Access
。
由于代码没有限制,攻击者可以在请求中添加一个新的 Forwarded 请求头,并将 for 值设置为 127.0.0.1,这样手动设置的 for 就会被添加到 Forwarded 末尾,从而在 httpsd 中能够控制 Client-IP 的值,实现身份验证绕过。
Node.js 鉴权绕过 简要总结了 CVE-2022-40684 漏洞成因,下面我们来看看在 7.0.x 版本中,对于 Websocket 请求是如何处理的。
以 7.0.8 版本为例,当用户发送一个 Websocket 建立请求时,Node.js 中会调用 WebAuth.getValidatedSession
来鉴权。
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 async getValidatedSession (request, options = {} ) { const authToken = await this ._extractToken (request); let session = null ; if (authToken) { const sessionEntry = webSession.get (authToken); if (sessionEntry) { this ._logger .info (`Token "${authToken} " found in session cache.` , {groupContext : options.groupContext }); session = sessionEntry.session ; } } if (!session) { session = await this ._getAdminSession (request, {groupContext : options.groupContext }); if (session) { session.vdoms = new Set (session.vdoms ); if (authToken) { webSession.add (authToken, session); this ._logger .info (`Token "${authToken} " added to session cache.` , {groupContext : options.groupContext }); } } } if (authToken) { if (!(await this ._csrfValidation (request, {groupContext : options.groupContext }))) { this ._logger .error ('Request fails CSRF validation.' , {groupContext : options.groupContext }); session = null ; } } return session; }
函数首先会使用 _extractToken
尝试从请求的 Cookie 中获取 authtoken,如果获取失败就调用 _getAdminSession
进行鉴权。
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 async _getAdminSession (request, options = {} ) { const { headers, url } = request; const query = querystring.parse (url.replace (/.*\?/ , '' )); const localToken = query.local_access_token ; const cookie = headers[':cookie' ] || headers.cookie ; const apiKey = !cookie ? await this ._extractToken (request) : null ; const authParams = ['monitor' , 'web-ui' , 'node-auth' ]; let authParamsFound = false ; let isFgfmReq = false ; try { isFgfmReq = nodeSockopt.isFgfmReq (request.socket ._handle .fd ); } catch (err) { this ._logger .warn (err, {groupContext : options.groupContext }); } if (isFgfmReq) { this ._logger .info ('FGFM request detected.' , {groupContext : options.groupContext }); authParams.push (this ._createFgfmFetchOptions (request)); authParamsFound = true ; } else if (cookie || apiKey) { if (apiKey) { authParams[authParams.length - 1 ] += `?${SYMBOLS.API_KEY_PARAM} =${apiKey} ` ; } authParams.push (this ._createFetchOptions (request)); authParamsFound = true ; } else if (localToken) { authParams[authParams.length - 1 ] += `?local_access_token=${localToken} ` ; authParamsFound = true ; } if (!authParamsFound) { this ._logger .warn ('No authorization headers found. Authentication failed.' , {groupContext : options.groupContext }); return null ; } try { this ._logger .warn ('Sending authentication request to REST API.' , {groupContext : options.groupContext }); return await new ApiFetch (...authParams); } catch (e) { this ._logger .warn (`Failed to authenticate user (${e} ).` , {groupContext : options.groupContext }); return null ; } }
分析这个函数逻辑,它主要支持 3 种身份验证方式:
Fgfm 模式,当请求来自于 FortiManager 时使用
请求存在 Cookie 请求头,就会调用 _createFetchOptions
构造身份认证参数
请求的 query string 中传入的 local access token,直接把传入的参数作为身份认证参数使用
核心就是通过不同方式来设置 authParams 参数值,最终会构造 ApiFetch
开始身份认证流程。
对于第二种方式,_createFetchOptions
函数内容如下
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 _createFetchOptions (request ) { const newOptions = {}; newOptions.headers = {}; if (request.headers ['user-agent' ] != null ) { newOptions.headers ['user-agent' ] = request.headers ['user-agent' ]; } if (request.headers .cookie != null ) { newOptions.headers .cookie = request.headers .cookie ; } if (request.headers ['x-csrftoken' ] != null ) { newOptions.headers ['x-csrftoken' ] = request.headers ['x-csrftoken' ]; } if (request.headers .authorization != null ) { newOptions.headers .authorization = request.headers .authorization ; } if (request.socket != null ) { const {localAddress, localPort, remoteAddress, remotePort} = request.socket ; newOptions.headers .Forwarded = `by="[${localAddress} ]:${localPort} ";` + `for="[${remoteAddress} ]:${remotePort} "` ; } return newOptions; }
这个函数的重点在于它会将 newOptions 即最终的 authParams 里面的 User-Agent 覆盖为请求中的 User-Agent,并且修改 Forwarded 请求头,将源 IP 地址设置为客户端的 socket 地址。这和前面提到的 CVE-2022-40684 有一定关系,代码限制了 Forwarded 可能的取值,用户也就无法伪造并控制 httpsd 中的 Client-IP 取值。
但当使用 local access token 方式进行验证时,代码只会设置一个 local_access_token
参数,不会调用 _createFetchOptions
。在这种情况下,authParams 各个参数就会取当前的默认值,最关键的是不会修改 Forwarded 请求头。
接着调用 ApiFetch 开始向后端发送鉴权请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 constructor ( ) { const args = Array .from (arguments ), segments = args.filter (param => typeof (param) === 'string' ), options = args.find (param => typeof (param) === 'object' ); const uri = segments.join ('/' ); const defaultHeaders = { 'user-agent' : SYMBOLS .NODE_USER_AGENT , 'accept-encoding' : 'gzip, deflate' }; this ._options = Object .assign ({}, options); this ._options .headers = Object .assign (defaultHeaders, options && options.headers ); this ._url = new URL (uri, apiURL); }
ApiFetch 的构造函数中,会将 authParams 设置为 args 数组,同时会把 User-Agent 设置为 Node.js,这时,发往后端的鉴权请求满足这样几个条件:
由于请求是从 Node.js 转发到 httpsd 中的,且 Forwarded 未被修改,因此源 IP 地址为 127.0.0.1
User-Agnet 被设置为 Node.js 字符串
也就是说这种情况刚好满足 API 身份验证函数 api_access_check_for_trusted_access
的条件,就可以通过身份验证。
Websocket 鉴权绕过 通过了 Node.js 身份验证后,js 中会构造 CliConnection 对象开始连接后端 CLI 服务。
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 constructor (ws, request, options, groupContext ) { const args = [ `"${request.headers['x-auth-login-name' ]} "` , `"${request.headers['x-auth-admin-name' ]} "` , `"${request.headers['x-auth-vdom' ]} "` , `"${request.headers['x-auth-profile' ]} "` , `"${request.headers['x-auth-admin-vdoms' ]} "` , `"${request.headers['x-auth-sso' ] || SYMBOLS.SSO_LOGIN_TYPE_NONE_STR} "` , request.headers ['x-forwarded-client' ], request.headers ['x-forwarded-local' ] ]; const csfAuth = request.headers ['x-auth-csf' ]; this .ws = ws; this .options = options; this .groupContext = groupContext; this .logInfo ('CLI websocket initialized.' ); const cli = this .cli = connect ({ port : 8023 , host : '127.0.0.1' , localAddress : '127.0.0.2' }); this .logInfo ('CLI connection established.' ); this .expectedGreetings = /Connected\./ ; this .loginContext = args.join (' ' ); this .logInfo (`${this .loginContext} \n` ); if (csfAuth) { this .loginContext += ` ${CSF_PROF_PATH} /${csfAuth} ` ; this ._csfAuth = csfAuth; } ws.on ('message' , msg => cli.write (msg)); cli.setNoDelay ().on ('data' , data => this .processData (data)); let wsClosed = false ; let cliDestroyed = false ; ws.on ('close' , () => { if (!wsClosed) { this .logInfo ('Websocket closed.' ); wsClosed = true ; } if (!cliDestroyed) { this .logInfo ('Destroying CLI connection.' ); cli.destroy (); cliDestroyed = true ; } }); cli.on ('close' , () => { if (!wsClosed) { this .logInfo ('Connection terminated by CLI.' ); } ws.close (); if (this ._csfAuth ) { this .logInfo ('Removing CSF CLI authentication file.' ); const filePath = `${CSF_PROF_PATH} /${this ._csfAuth} ` ; fs.unlink (filePath, () => {}); } }); }
函数首先会向最终发往后端的请求中添加一些参数,这些参数的取值来自于当前 session 对象,也就是 WebAuth.getValidatedSession
函数的返回值。
当利用 Node.js 身份验证绕过来到此处时,由于绕过的是 API 鉴权,因此当前用户角色应该是 Local_Process_Access
,设置好一系列参数后,会通过 socket 连接到 127.0.0.1:8023,也就是 CLI 服务的端口,接着通过 ws.on
绑定了 message 事件,当 websocket 接收到客户端的数据时,就会立刻发送给 CLI 服务。
随后又绑定了 CLI 的 socket 事件,当 CLI 程序发送过来数据时,会触发 processData 函数
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 processData (buf ) { const cli = this .cli ; let opt, i = 0 ; for (i = 0 ; i + 2 < buf.length && buf[i] === CMD .IAC ;) { const cmd = buf[i + 1 ]; switch (cmd) { case CMD .WILL : opt = buf[i + 2 ]; eat (3 ); break ; case CMD .DO : opt = buf[i + 2 ]; switch (opt) { case OPT .NAWS : this .telnetCommand (CMD .WILL , OPT .NAWS ); this .resizeCLI (this .options .cols , this .options .rows ); break ; default : this .telnetCommand (CMD .WONT , opt); } eat (3 ); break ; case CMD .DONT : opt = buf[i + 2 ]; eat (3 ); break ; default : this .logError ('Unknown opcode %d' , buf[i + 1 ]); eat (2 ); } } const data = buf.slice (i); if (data) { const ws = this .ws ; if (this .expectedGreetings ) { if (data.toString ().match (this .expectedGreetings )) { this .logInfo ('Parsed expected greeting' ); this .expectedGreetings = null ; this .telnetCommand (CMD .DONT , OPT .ECHO ); this .logInfo ('Sending login context' ); cli.write (`${this .loginContext} \n` ); this .setup (); } return ; } ws.send (data); } function eat (n ) { i += n; } }
这个函数关键在于,它会使用 cli.write 将之前设置的登录信息发送给 CLI 程序进行登录操作。绕过 API 鉴权生成的登录信息如下
1 "Local_Process_Access" "Local_Process_Access" "root" "" "" "none" [192.168.66.1]:50296 [192.168.66.161]:80\n
Local_Process_Access
角色并不能通过 CLI 程序的鉴权,因为该用户不具有 CLI 执行权限。
考虑 Node.js 中的事件绑定逻辑关系,代码先绑定了 websocket 的 message 事件,后绑定 CLI socket 数据事件。我们知道通过 Socket 连接到 CLI 服务之后,CLI 服务肯定会先返回一些数据,要求进行身份验证,此时会先触发 Node.js 中的 CLI socket 事件,令 Node.js 发送不可控的登录信息,这种情况下无法绕过 CLI 鉴权。
但当 Websocket 连接成功建立之后,我们可以尝试立刻向 Websocket 发送一份伪造的登录信息,在理想情况下,这个伪造的信息会优先于 processData 中合法信息到达 CLI 程序,这样就可以绕过 CLI 身份验证。
例如一个伪造的登录信息可以是:
1 "admin" "admin" "root" "super_admin" "root" "none" [127.0.0.1]:12345 [127.0.0.1]:12346\n
通过不断连接 Websocket 并发送登录数据,最终就可以实现 CLI 登录绕过,并以管理员身份访问 CLI 服务。这也符合公告中 IOC 情报:攻击者可以控制最终的源和目的 IP 地址。
参考链接 https://fortiguard.fortinet.com/psirt/FG-IR-24-535
https://www.fortiguard.com/psirt/FG-IR-22-377