From 6e4e4717920ab93cfab61e6cde0b0d9a1bacdc8b Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Wed, 20 Aug 2025 12:33:32 -0400 Subject: [PATCH] fix(lib): ensure issued challenges don't get double-spent (#1003) * fix(lib): ensure issued challenges don't get double-spent Closes #1002 TL;DR: challenge IDs were not validated at time of token issuance. A dedicated attacker could solve a challenge once and reuse it across multiple sessons in order to mint additional tokens. With the advent of store based challenge issuance in #749, this means that these challenge IDs are only good for 30 minutes. Websites using the most recent version of Anubis have limited exposure to this problem. Websites using older versions of Anubis have a much more increased exposure to this problem and are encouraged to keep this software updated as often and as frequently as possible. * docs: update CHANGELOG Signed-off-by: Xe Iaso --------- Signed-off-by: Xe Iaso --- .github/actions/spelling/expect.txt | 1 + docs/docs/CHANGELOG.md | 16 ++++++++++++++++ lib/anubis.go | 12 ++++++++++++ lib/challenge/challenge.go | 1 + 4 files changed, 30 insertions(+) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 76e2060..7e0cb5f 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -287,6 +287,7 @@ SVCNAME tagline tarballs tarrif +taviso tbn tbr techaro diff --git a/docs/docs/CHANGELOG.md b/docs/docs/CHANGELOG.md index 7811bed..ad1dcdc 100644 --- a/docs/docs/CHANGELOG.md +++ b/docs/docs/CHANGELOG.md @@ -39,6 +39,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The word "hack" has been removed from the translation strings for Anubis due to incidents involving people misunderstanding that word and sending particularly horrible things to the project lead over email. - Bump AI-robots.txt to version 1.39 +### Security-relevant changes + +#### Fix potential double-spend for challenges + +Anubis operates by issuing a challenge and having the client present a solution for that challenge. Challenges are identified by a unique UUID, which is tored in the database. + +The problem is that a challenge could potentially be used twice by a dedicated attacker making a targeted attack against Anubis. Challenge records did not have a "spent" or "used" field. In total, a dedicated attacker could solve a challenge once and reuse that solution across multiple sessions in order to mint additional tokens. + +This was fixed by adding a "spent" field to challenges in the data store. When a challenge is solved, that "spent" field gets set to `true`. If a future attempt to solve this challenge is observed, it gets rejected. + +With the advent of store based challenge issuance in [#749](https://github.com/TecharoHQ/anubis/pull/749), this means that these challenge IDs are [only good for 30 minutes](https://github.com/TecharoHQ/anubis/blob/e8dfff635015d6c906dddd49cb0eaf591326092a/lib/anubis.go#L130-L135d). Websites using the most recent version of Anubis have limited exposure to this problem. + +Websites using older versions of Anubis have a much more increased exposure to this problem and are encouraged to keep this software updated as often and as frequently as possible. + +Thanks to [@taviso](https://github.com/taviso) for reporting this issue. + ### Breaking changes - The "slow" frontend solver has been removed in order to reduce maintenance burden. Any existing uses of it will still work, but issue a warning upon startup asking administrators to upgrade to the "fast" frontend solver. diff --git a/lib/anubis.go b/lib/anubis.go index af7238d..3fd9e68 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -454,6 +454,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { return } + if chall.Spent { + lg.Error("double spend prevented", "reason", "double_spend") + s.respondWithError(w, r, fmt.Sprintf("%s: %s", localizer.T("internal_server_error"), "double_spend")) + return + } + impl, ok := challenge.Get(chall.Method) if !ok { lg.Error("check failed", "err", err) @@ -527,6 +533,12 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { s.SetCookie(w, CookieOpts{Path: cookiePath, Host: r.Host, Value: tokenString}) + chall.Spent = true + j := store.JSON[challenge.Challenge]{Underlying: s.store} + if err := j.Set(r.Context(), "challenge:"+chall.ID, *chall, 30*time.Minute); err != nil { + lg.Debug("can't update information about challenge", "err", err) + } + challengesValidated.WithLabelValues(rule.Challenge.Algorithm).Inc() lg.Debug("challenge passed, redirecting to app") http.Redirect(w, r, redir, http.StatusFound) diff --git a/lib/challenge/challenge.go b/lib/challenge/challenge.go index 1200e33..2553d0c 100644 --- a/lib/challenge/challenge.go +++ b/lib/challenge/challenge.go @@ -9,4 +9,5 @@ type Challenge struct { RandomData string `json:"randomData"` // The random data the client processes IssuedAt time.Time `json:"issuedAt"` // When the challenge was issued Metadata map[string]string `json:"metadata"` // Challenge metadata such as IP address and user agent + Spent bool `json:"spent"` // Has the challenge already been solved? }