Files
root-me-api/internal/anubis/challenge.go
T
mycroft cf961ab94f
build / test (push) Successful in 9s
build / build (push) Successful in 10s
build / build-image (push) Successful in 1m4s
feat: initial commit
2026-06-04 14:19:37 +02:00

112 lines
3.2 KiB
Go

package anubis
import (
"encoding/json"
"fmt"
"regexp"
)
// Anubis embeds several JSON blobs in its interstitial as
// <script id="...">{...}</script> elements. We read two:
//
// - anubis_challenge: the canonical challenge (id, randomData, rules)
// - preact_info: the preact frontend's view, crucially the `redir`
// (the fully-formed pass-challenge URL) and `challenge` string.
//
// root-me.org uses the "preact" challenge: the answer is simply
// SHA-256(randomData) (no proof-of-work nonce), gated by a minimum wait of
// difficulty*80ms server-side.
type anubisChallenge struct {
Challenge struct {
ID string `json:"id"`
Method string `json:"method"`
RandomData string `json:"randomData"`
IssuedAt string `json:"issuedAt"`
} `json:"challenge"`
Rules struct {
Difficulty int `json:"difficulty"`
Algorithm string `json:"algorithm"`
} `json:"rules"`
}
type preactInfo struct {
Challenge string `json:"challenge"`
Difficulty int `json:"difficulty"`
Redir string `json:"redir"` // relative pass-challenge URL with id+redir baked in
}
// challenge is the normalized view the transport acts on.
type challenge struct {
algorithm string
id string
randomData string
difficulty int
redir string // preact: server-provided relative pass-challenge URL
}
func scriptRe(id string) *regexp.Regexp {
return regexp.MustCompile(
`(?is)<script[^>]*\bid=["']` + regexp.QuoteMeta(id) + `["'][^>]*>(.*?)</script>`)
}
var (
anubisChallengeRe = scriptRe("anubis_challenge")
preactInfoRe = scriptRe("preact_info")
)
// isInterstitial reports whether an HTML body is an Anubis challenge page.
func isInterstitial(body []byte) bool {
return anubisChallengeRe.Match(body)
}
func extractJSON(re *regexp.Regexp, body []byte, into any) error {
m := re.FindSubmatch(body)
if m == nil {
return fmt.Errorf("anubis: script element not found")
}
if err := json.Unmarshal(m[1], into); err != nil {
return fmt.Errorf("anubis: decode embedded JSON: %w", err)
}
return nil
}
// parseChallenge extracts and normalizes the Anubis challenge from
// interstitial HTML.
func parseChallenge(body []byte) (*challenge, error) {
var ac anubisChallenge
if err := extractJSON(anubisChallengeRe, body, &ac); err != nil {
return nil, fmt.Errorf("anubis: parse anubis_challenge: %w", err)
}
if ac.Challenge.RandomData == "" || ac.Rules.Difficulty <= 0 {
return nil, fmt.Errorf("anubis: incomplete challenge (randomData=%q difficulty=%d)",
ac.Challenge.RandomData, ac.Rules.Difficulty)
}
c := &challenge{
algorithm: ac.Rules.Algorithm,
id: ac.Challenge.ID,
randomData: ac.Challenge.RandomData,
difficulty: ac.Rules.Difficulty,
}
// The preact challenge carries the fully-formed pass-challenge URL in
// preact_info.redir; grab it when present.
if c.algorithm == "preact" {
var pi preactInfo
if err := extractJSON(preactInfoRe, body, &pi); err != nil {
return nil, fmt.Errorf("anubis: parse preact_info: %w", err)
}
if pi.Redir == "" {
return nil, fmt.Errorf("anubis: preact_info missing redir")
}
c.redir = pi.Redir
if pi.Challenge != "" {
c.randomData = pi.Challenge
}
if pi.Difficulty > 0 {
c.difficulty = pi.Difficulty
}
}
return c, nil
}