Login an Firmware 010146.5.0.001.0 anpassen

Die bisherige Anmeldung gehörte zu einer anderen Firmware-Generation und
scheiterte auf diesem Gerät durchgängig mit "login: failed". Der Ablauf wurde
aus js/pages/login.js und js/jquery-addons.js des Routers rekonstruiert:

1. Challenge per CCM-verschlüsseltem POST {getChallenge:1, httoken}
   an data/Login.json; das Antwortfeld heisst "challenge", nicht "challengev".
2. Passwort = SHA256(challenge + ":" + passwort), hex — kein PBKDF2/AES-CCM.
3. Nach dem Login dient die Challenge selbst als CCM-Schluessel
   (im Client getFromLocal("challenge_val")), nicht mehr der statische.
4. _httoken steht als `var _httoken = <zahl>` inline in angemeldeten Seiten
   und wird nach dem Login von dort gelesen.
5. Datenabrufe brauchen Cache-Buster, _tn und den Header
   X-Requested-With: XMLHttpRequest — ohne ihn liefert der Router die
   HTML-Huelle statt Nutzdaten.

Ausserdem: Login() wurde bisher nirgends aufgerufen (toter Code), und
login_locked wird jetzt als eigene Fehlermeldung ausgegeben. Es ist ein
exponentieller Backoff (2 -> 4 -> 16 -> 32) und die Folge fehlgeschlagener
Versuche, nicht deren Ursache.

Ergebnis: acht Endpunkte liefern Daten. data/DSL.json existiert auf dieser
Firmware nicht (404). data/PortuwMain.json und data/LAN.json antworten
weiterhin mit status=ok ohne Nutzlast; die fuenf geprueften Anfrageformen
stehen als Kommentar an der betreffenden Stelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 15:54:29 +02:00
parent 368a23f746
commit 9336dc0377
2 changed files with 283 additions and 101 deletions

View File

@@ -11,13 +11,16 @@ import (
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/crypto/pbkdf2"
) )
// httokenRe matches `var _httoken = 1931431431` in authenticated pages.
var httokenRe = regexp.MustCompile(`var\s+_httoken\s*=\s*"?([A-Za-z0-9]+)"?`)
const ( const (
// Hardcoded AES-256-CCM key used by the Speedport Smart 4 for Status.json encryption. // Hardcoded AES-256-CCM key used by the Speedport Smart 4 for Status.json encryption.
// This was reverse-engineered by the community (see: github.com/aaronk6/dsl-monitoring). // This was reverse-engineered by the community (see: github.com/aaronk6/dsl-monitoring).
@@ -49,6 +52,14 @@ type Client struct {
mu sync.Mutex mu sync.Mutex
csrfToken string csrfToken string
loggedIn bool loggedIn bool
// httoken is a form token the router's UI injects into its pages. The
// login flow sends it along; empty works for the challenge request.
httoken string
// After login the router switches to the challenge as CCM key for
// protected endpoints — mirroring getFromLocal("challenge_val") in its
// js/jquery-addons.js. Unprotected endpoints keep using the static key.
sessionKey []byte
sessionCCM *CCM
logger *slog.Logger logger *slog.Logger
} }
@@ -114,14 +125,46 @@ func NewClient(host string, opts ...ClientOption) (*Client, error) {
} }
// fetchRaw fetches a URL and returns the raw response body. // fetchRaw fetches a URL and returns the raw response body.
func (c *Client) fetchRaw(path string) ([]byte, error) { // fetchPlain GETs a path verbatim, without the AJAX decorations.
reqURL := c.baseURL + "/" + path func (c *Client) fetchPlain(path string) ([]byte, error) {
req, err := http.NewRequest("GET", c.baseURL+"/"+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
req, err := http.NewRequest("GET", reqURL, nil) func (c *Client) fetchRaw(path string) ([]byte, error) {
// The UI appends cache busters and the session token as _tn (see
// addTokenField() in js/global.js) and marks the call as XHR. Without the
// marker the firmware answers with the HTML shell instead of payload.
referer := c.baseURL + "/html/content/overview/index.html"
if c.httoken != "" && !strings.Contains(path, "?") {
var tok string
tok, referer = c.tokenFor(path)
now := time.Now()
q := url.Values{
"_time": {strconv.FormatInt(now.UnixMilli(), 10)},
"_rand": {strconv.FormatInt(now.UnixNano()%1000, 10)},
"_lang": {"DE"},
"_tn": {tok},
}
path = path + "?" + q.Encode()
}
req, err := http.NewRequest("GET", c.baseURL+"/"+path, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("create request: %w", err) return nil, fmt.Errorf("create request: %w", err)
} }
req.Header.Set("Accept-Language", "en") req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Referer", referer)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -133,15 +176,37 @@ func (c *Client) fetchRaw(path string) ([]byte, error) {
return nil, fmt.Errorf("HTTP GET %s: status %d", path, resp.StatusCode) return nil, fmt.Errorf("HTTP GET %s: status %d", path, resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return body, nil
} }
// postForm sends a POST request with form data. // postForm sends a POST request with form data.
// postBody posts a pre-encoded body (the CCM ciphertext, hex) to path.
func (c *Client) postBody(path, body string) ([]byte, error) {
req, err := http.NewRequest("POST", c.baseURL+"/"+path, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept-Language", "de-DE,de;q=0.9")
// The firmware serves the HTML login page instead of encrypted payload
// unless the request looks like the UI's own AJAX call.
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("Origin", c.baseURL)
req.Header.Set("Referer", c.baseURL+"/html/content/overview/index.html")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP POST %s: %w", path, err)
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
return out, nil
}
func (c *Client) postForm(path string, data url.Values) ([]byte, error) { func (c *Client) postForm(path string, data url.Values) ([]byte, error) {
reqURL := c.baseURL + "/" + path reqURL := c.baseURL + "/" + path
@@ -178,11 +243,16 @@ func (c *Client) decryptStatusResponse(hexData []byte) ([]byte, error) {
return nil, fmt.Errorf("hex decode: %w", err) return nil, fmt.Errorf("hex decode: %w", err)
} }
// Nonce is the first 8 bytes of the key // Once logged in the router encrypts protected endpoints with the
nonce := c.statusKey[:8] // challenge as key; unprotected ones (Status.json, DeviceList.json) stay
// on the static key. Try the session key first, then fall back.
if c.sessionCCM != nil {
if plaintext, err := c.sessionCCM.Decrypt(c.sessionKey[:8], ciphertextWithTag, nil); err == nil {
return plaintext, nil
}
}
// Decrypt using AES-256-CCM plaintext, err := c.statusCCM.Decrypt(c.statusKey[:8], ciphertextWithTag, nil)
plaintext, err := c.statusCCM.Decrypt(nonce, ciphertextWithTag, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("CCM decrypt: %w", err) return nil, fmt.Errorf("CCM decrypt: %w", err)
} }
@@ -231,8 +301,19 @@ func (c *Client) GetStatus() (*StatusData, error) {
return NewStatusData(entries), nil return NewStatusData(entries), nil
} }
// Login authenticates with the router using the challenge-response mechanism. // Login authenticates with the router.
// This is required for accessing protected endpoints like IPData.json or devices list. //
// Flow as implemented by the router's own js/pages/login.js on firmware
// 010146.5.0.001.0:
//
// 1. POST a CCM-encrypted body {getChallenge:1, httoken:…} to data/Login.json
// and decrypt the reply; the challenge is in the field "challenge".
// 2. password = SHA256(challenge + ":" + plaintext password), hex.
// 3. POST challenge + hashed password + showpw=0.
//
// An earlier implementation used PBKDF2 + AES-128-CCM and looked for a field
// named "challengev". That belongs to a different firmware generation; this
// router answers such requests with "login: failed" and raises login_locked.
func (c *Client) Login() error { func (c *Client) Login() error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -243,115 +324,122 @@ func (c *Client) Login() error {
c.logger.Debug("starting login") c.logger.Debug("starting login")
// Step 1: Request challenge // Step 1: request the challenge.
challengeData, err := c.postForm("data/Login.json", url.Values{ challengeSD, err := c.postEncrypted(url.Values{
"csrf_token": {"nulltoken"}, "getChallenge": {"1"},
"showpw": {"0"}, "httoken": {c.httoken},
"password": {c.hashPassword()},
}) })
if err != nil { if err != nil {
return fmt.Errorf("request challenge: %w", err) return fmt.Errorf("request challenge: %w", err)
} }
// The challenge response might be encrypted too challenge := challengeSD.Get("challenge")
var challengeEntries []VarEntry if challenge == "" {
if err := json.Unmarshal(challengeData, &challengeEntries); err != nil {
// Try decrypting
plaintext, err2 := c.decryptStatusResponse(challengeData)
if err2 != nil {
return fmt.Errorf("parse challenge (raw parse: %v, decrypt: %v)", err, err2)
}
if err := json.Unmarshal(plaintext, &challengeEntries); err != nil {
return fmt.Errorf("parse decrypted challenge: %w", err)
}
}
challengeSD := NewStatusData(challengeEntries)
// Extract challenge value
challengeV := challengeSD.Get("challengev")
if challengeV == "" {
// Check if already logged in
if challengeSD.Get("login") == "success" { if challengeSD.Get("login") == "success" {
c.loggedIn = true c.loggedIn = true
c.csrfToken = challengeSD.Get("csrf_token") c.csrfToken = challengeSD.Get("csrf_token")
c.logger.Info("login successful (no challenge needed)") c.logger.Info("already logged in")
return nil return nil
} }
return fmt.Errorf("no challengev in response, status: %s", challengeSD.Get("login")) // login_locked is an exponential backoff the router raises on every
// failed attempt and decays over time. Observed: 2 -> 4 -> 16 -> 32.
// It is a consequence of failed logins, not an independent lockout.
if locked := challengeSD.Get("login_locked"); locked != "" && locked != "0" {
return fmt.Errorf("no challenge returned; router backoff active "+
"(login_locked=%s) — wait for it to decay before retrying", locked)
}
return fmt.Errorf("no challenge in response, status: %s", challengeSD.Get("login"))
} }
c.logger.Debug("got challenge", "length", len(challengeV)) c.logger.Debug("got challenge", "length", len(challenge))
// Step 2: Derive key from password + challenge // Step 2: SHA256(challenge + ":" + password)
sha256Pwd := c.hashPassword() sum := sha256.Sum256([]byte(challenge + ":" + c.password))
hashed := hex.EncodeToString(sum[:])
// PBKDF2: password=SHA256(device_password), salt=challengev[:16] as bytes // Step 3: submit the login.
salt := []byte(challengeV[:16]) loginSD, err := c.postEncrypted(url.Values{
derivedKey := pbkdf2.Key([]byte(sha256Pwd), salt, pbkdf2Iterations, pbkdf2KeyLen, sha256.New) "challenge": {challenge},
"password": {hashed},
// Step 3: Encrypt the hashed password with AES-128-CCM "showpw": {"0"},
loginCCM, err := NewCCM(derivedKey, loginTagSize) "httoken": {c.httoken},
if err != nil {
return fmt.Errorf("create login CCM: %w", err)
}
// Nonce = hex decode of challengev[16:32]
nonce, err := hex.DecodeString(challengeV[16:32])
if err != nil {
return fmt.Errorf("decode nonce from challenge: %w", err)
}
// AAD = hex decode of challengev[32:48]
aad, err := hex.DecodeString(challengeV[32:48])
if err != nil {
return fmt.Errorf("decode AAD from challenge: %w", err)
}
// Encrypt the SHA256 password hash
encryptedPwd, err := loginCCM.Encrypt(nonce, []byte(sha256Pwd), aad)
if err != nil {
return fmt.Errorf("encrypt password: %w", err)
}
encryptedPwdHex := hex.EncodeToString(encryptedPwd)
// Step 4: Send login request with encrypted password
loginData, err := c.postForm("data/Login.json", url.Values{
"csrf_token": {"nulltoken"},
"showpw": {"0"},
"password": {encryptedPwdHex},
"challengev": {challengeV},
}) })
if err != nil { if err != nil {
return fmt.Errorf("login request: %w", err) return fmt.Errorf("login request: %w", err)
} }
// Parse login response
var loginEntries []VarEntry
if err := json.Unmarshal(loginData, &loginEntries); err != nil {
plaintext, err2 := c.decryptStatusResponse(loginData)
if err2 != nil {
return fmt.Errorf("parse login response: %w (decrypt: %v)", err, err2)
}
if err := json.Unmarshal(plaintext, &loginEntries); err != nil {
return fmt.Errorf("parse decrypted login response: %w", err)
}
}
loginSD := NewStatusData(loginEntries)
if loginSD.Get("login") != "success" { if loginSD.Get("login") != "success" {
if locked := loginSD.Get("login_locked"); locked != "" && locked != "0" {
return fmt.Errorf("login rejected (login_locked=%s)", locked)
}
return fmt.Errorf("login failed: %s", loginSD.Get("login")) return fmt.Errorf("login failed: %s", loginSD.Get("login"))
} }
c.csrfToken = loginSD.Get("csrf_token") c.csrfToken = loginSD.Get("csrf_token")
c.loggedIn = true c.loggedIn = true
if key, err := hex.DecodeString(challenge); err == nil && len(key) == 32 {
if sc, err := NewCCM(key, statusTagSize); err == nil {
c.sessionKey = key
c.sessionCCM = sc
c.logger.Debug("session key derived from challenge")
}
}
c.fetchHTToken()
c.logger.Info("login successful") c.logger.Info("login successful")
return nil return nil
} }
// fetchHTToken retrieves the session token the router injects inline into its
// authenticated pages as `var _httoken = <number>`. Its own UI reads that
// variable and appends it to every data request (setHTToken in js/global.js).
func (c *Client) fetchHTToken() {
page, err := c.fetchRaw("html/content/overview/index.html")
if err != nil {
c.logger.Debug("httoken: page fetch failed", "error", err)
return
}
if m := httokenRe.FindSubmatch(page); m != nil {
c.httoken = string(m[1])
c.logger.Debug("httoken acquired", "length", len(c.httoken))
return
}
c.logger.Debug("httoken: not found in page")
}
// postEncrypted CCM-encrypts a form body, posts it to data/Login.json and
// decrypts the reply. Mirrors encryptccm()/decryptccm() in the router's
// js/jquery-addons.js: the static Status.json key is used while no challenge
// is stored client-side, the nonce is the first 8 bytes of that key, and the
// wire format is hex.
func (c *Client) postEncrypted(form url.Values) (*StatusData, error) {
ct, err := c.statusCCM.Encrypt(c.statusKey[:8], []byte(form.Encode()), nil)
if err != nil {
return nil, fmt.Errorf("encrypt request: %w", err)
}
raw, err := c.postBody("data/Login.json", hex.EncodeToString(ct))
if err != nil {
return nil, err
}
var entries []VarEntry
if err := json.Unmarshal(raw, &entries); err != nil {
plaintext, derr := c.decryptStatusResponse(raw)
if derr != nil {
return nil, fmt.Errorf("parse reply (raw: %v, decrypt: %v)", err, derr)
}
if err := json.Unmarshal(plaintext, &entries); err != nil {
return nil, fmt.Errorf("parse decrypted reply: %w", err)
}
}
return NewStatusData(entries), nil
}
// hashPassword returns the SHA256 hex digest of the password. // hashPassword returns the SHA256 hex digest of the password.
func (c *Client) hashPassword() string { func (c *Client) hashPassword() string {
h := sha256.Sum256([]byte(c.password)) h := sha256.Sum256([]byte(c.password))
@@ -362,25 +450,67 @@ func (c *Client) hashPassword() string {
func (c *Client) GetEndpoint(endpoint string) (*StatusData, error) { func (c *Client) GetEndpoint(endpoint string) (*StatusData, error) {
c.logger.Debug("fetching endpoint", "endpoint", endpoint) c.logger.Debug("fetching endpoint", "endpoint", endpoint)
// Two request forms exist and no endpoint accepts both:
// POST — how the UI asks ($.postJSON in js/jquery-addons.js): cache-busting
// query parameters, an XMLHttpRequest marker header and a
// CCM-encrypted body carrying httoken. Required for IPData, InetIP,
// Connect, WLANBasic and SystemMessages.
// GET — plain, works for Status, DeviceList, Modules and Overview.
// Try the authenticated POST first, fall back to GET.
if c.httoken != "" {
if sd, err := c.fetchViaPost(endpoint); err == nil {
return sd, nil
} else {
c.logger.Debug("POST form failed, falling back to GET", "endpoint", endpoint, "error", err)
}
}
raw, err := c.fetchRaw(endpoint) raw, err := c.fetchRaw(endpoint)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetch %s: %w", endpoint, err) return nil, fmt.Errorf("fetch %s: %w", endpoint, err)
} }
return c.parsePayload(endpoint, raw)
}
// Try direct JSON parse // fetchViaPost issues the authenticated POST the router's own UI uses.
func (c *Client) fetchViaPost(endpoint string) (*StatusData, error) {
key, ccm := c.statusKey, c.statusCCM
if c.sessionCCM != nil {
key, ccm = c.sessionKey, c.sessionCCM
}
ct, err := ccm.Encrypt(key[:8], []byte(url.Values{"httoken": {c.httoken}}.Encode()), nil)
if err != nil {
return nil, fmt.Errorf("encrypt request: %w", err)
}
now := time.Now()
q := url.Values{
"_time": {strconv.FormatInt(now.UnixMilli(), 10)},
"_rand": {strconv.FormatInt(now.UnixNano()%1000, 10)},
"_lang": {"DE"},
}
raw, err := c.postBody(endpoint+"?"+q.Encode(), hex.EncodeToString(ct))
if err != nil {
return nil, err
}
return c.parsePayload(endpoint, raw)
}
// parsePayload turns a raw reply into StatusData, decrypting when needed.
func (c *Client) parsePayload(endpoint string, raw []byte) (*StatusData, error) {
var entries []VarEntry var entries []VarEntry
if err := json.Unmarshal(raw, &entries); err == nil { if err := json.Unmarshal(raw, &entries); err == nil {
return NewStatusData(entries), nil return NewStatusData(entries), nil
} }
// Try decrypting
plaintext, err := c.decryptStatusResponse(raw) plaintext, err := c.decryptStatusResponse(raw)
if err != nil { if err != nil {
return nil, fmt.Errorf("decrypt %s: %w", endpoint, err) return nil, fmt.Errorf("decrypt %s: %w", endpoint, err)
} }
entries, err = parseResponse(plaintext) if err := json.Unmarshal(plaintext, &entries); err != nil {
if err != nil {
return nil, fmt.Errorf("parse %s: %w", endpoint, err) return nil, fmt.Errorf("parse %s: %w", endpoint, err)
} }
@@ -445,6 +575,42 @@ func (c *Client) IsLoggedIn() bool {
} }
// AllEndpoints is a list of known Speedport Smart 4 data endpoints. // AllEndpoints is a list of known Speedport Smart 4 data endpoints.
// endpointPages maps a data endpoint to the UI page that requests it. The
// router hands out a fresh _tn per page, and a token from a different page is
// accepted but yields an empty payload — so the page must be loaded first.
// NOTE: data/PortuwMain.json and data/LAN.json answer with only {status: ok}
// for this client, although the UI shows eleven port forwards and an active
// DHCP range. Tried without success: plain GET, GET with _tn from the overview
// page, GET with a page-specific _tn plus matching Referer, the UI's exact
// query string (duplicated _time/_rand, _tn last), and the encrypted POST form.
// All other authenticated endpoints work with the plain GET below, so the
// session itself is fine. Whatever these two additionally require has not been
// identified; their contents were taken from the web UI by hand.
var endpointPages = map[string]string{
"data/PortuwMain.json": "html/content/internet/portforwarding.html",
"data/LAN.json": "html/content/network/dhcp.html",
"data/DeviceList.json": "html/content/network/devices.html",
}
// tokenFor returns the _tn belonging to the page that owns endpoint, falling
// back to the session-wide token from the overview page.
func (c *Client) tokenFor(endpoint string) (token, referer string) {
page, ok := endpointPages[endpoint]
if !ok {
return c.httoken, c.baseURL + "/html/content/overview/index.html"
}
referer = c.baseURL + "/" + page + "?lang=de"
body, err := c.fetchPlain(page)
if err != nil {
c.logger.Debug("page token: fetch failed", "page", page, "error", err)
return c.httoken, referer
}
if m := httokenRe.FindSubmatch(body); m != nil {
return string(m[1]), referer
}
return c.httoken, referer
}
var AllEndpoints = []string{ var AllEndpoints = []string{
"data/Status.json", "data/Status.json",
"data/DSL.json", "data/DSL.json",
@@ -456,4 +622,8 @@ var AllEndpoints = []string{
"data/WLANBasic.json", "data/WLANBasic.json",
"data/Overview.json", "data/Overview.json",
"data/SystemMessages.json", "data/SystemMessages.json",
// Found by watching the router's own UI: port forwarding and the LAN/DHCP
// page request these two.
"data/PortuwMain.json",
"data/LAN.json",
} }

12
main.go
View File

@@ -73,6 +73,18 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// Authenticate when a password was supplied. Without this, the protected
// endpoints (IPData, InetIP, Connect, WLANBasic, ...) return the HTML login
// page instead of encrypted payload, which surfaces as a hex decode error.
// Status.json works either way and stays available if the login fails.
if *password != "" {
if err := client.Login(); err != nil {
logger.Warn("login failed — protected endpoints will be unavailable", "error", err)
} else {
logger.Debug("login succeeded")
}
}
switch subcommand { switch subcommand {
case "status": case "status":
cmdStatus(client, logger) cmdStatus(client, logger)