Du hast das komplette webpack-Bundle gesichert, den AST zerlegt, die elementaren Bausteine katalogisiert und kannst bekannte Fingerprints inzwischen zuverlässig lesen. Dann taucht in einem Request-Header ein Signaturwert auf: bei jedem Aufruf anders, offenbar irgendwo erzeugt – nur findest du die erzeugende Funktion nirgends. Du durchsuchst sämtliche statischen Assets nach verdächtigen Bitoperationen und Hash-Mustern. Ohne Treffer.
Das heißt nicht, dass deine Suche schlecht war. Die gesuchte Logik war schlicht nicht Teil des Codes, den du heruntergeladen hast.
Bei einer ganzen Klasse von Signaturen muss statische Analyse zwangsläufig ins Leere laufen: Der Algorithmus erscheint erst zur Laufzeit. In diesem Fall bringt es nichts, weiter nach einer nicht vorhandenen Funktion zu suchen. Der bessere Ansatz lautet: nicht den Algorithmus nachbauen, sondern ihn mit möglichst wenig Aufwand ausführen oder seinen Wert auslesen.
Zwei Hinweise, dass statische Analyse hier nicht weiterkommt
Zuerst solltest du sicherstellen, dass du wirklich auf diesen Zieltyp gestoßen bist und nicht bloß etwas übersehen hast. Zwei Indizien sind besonders eindeutig.
Erstens: Der Wert wechselt pro Sitzung oder Request, doch in den statischen Assets gibt es keinen Code, der ihn erzeugt. Im Netzwerkverkehr siehst du den Wert, nach jedem Reload einen anderen. Du lädst alle .js-Dateien herunter, durchsuchst sie per Volltextsuche – und nirgends wird der Wert zusammengesetzt. Der erzeugende Code wird erst zur Laufzeit ausgeliefert.
Zweitens: Der Wert lässt sich als Literal im Bundle finden, aber was gestern noch funktionierte, ist heute unbrauchbar. In der Build-Ausgabe steht eine gewöhnliche String-Konstante, die am selben Tag noch funktioniert, wenn du sie übernimmst. Einige Tage später liefert der Endpoint einen Fehler. Beim erneuten Nachsehen ist die vermeintliche Konstante durch ein anderes Literal ersetzt worden, das du wiederum im Bundle findest. Sie ist beim Build fest eingebrannt, wird aber mit jedem Frontend-Release ausgetauscht.
Beiden Fällen ist gemeinsam, dass die statische Sicht nur eine Momentaufnahme ist, während sich die maßgebliche Information über Zeit oder je Sitzung verändert. Entweder suchst du im Code und findest nichts, oder du findest den Wert, aber er lebt weiter. In beiden Fällen funktioniert „einmal statisch dumpen und kopieren“ nicht mehr. Das ist etwas anderes als „die Algorithmusfamilie nicht erkennen können“. Es liegt nicht an schwachem Fingerprint-Matching. Das passende Muster befindet sich einfach nicht in der Kopie des Codes, die vor dir liegt.
Typ eins: Der Server liefert den auszuführenden Challenge-Code
Beim ersten Typ läuft es so ab: Bevor du einen bestimmten Endpoint erreichst, liefert der Server ein einmaliges JavaScript aus. Das Skript erzeugt im Browser ein Cookie oder Token, das du für den weiteren Zugriff mitführen musst. Sein Inhalt unterscheidet sich meist je Sitzung, manchmal sogar je Request.
Warum statische Analyse hier zwangsläufig scheitert: Die Logik kommt erst zur Laufzeit und steckt nicht im statischen Bundle. Ein Skript, das du zufällig mitgeschnitten hast, ist lediglich eine einzelne Instanz. Was du heute sicherst und was morgen ausgeliefert wird, können zwei völlig verschiedene Codefragmente sein. Wer es reverset, arbeitet gegen ein bewegliches Ziel.
Die richtige Strategie ist, es als Black Box auszuführen. Du musst nicht verstehen, was berechnet wird. Es genügt, eine ausreichend realistische Umgebung bereitzustellen, das Skript bis zum Ergebnis laufen zu lassen und dieses Ergebnis abzugreifen. Praktisch heißt das:
Eine minimale Browser-Umgebung nachbilden: leere Stubs für
document,location,navigator,cookiesowie einige Observers und Timer – gerade weit genug ergänzt, bis das Skript nicht mehr wegen eines fehlenden globalen Objekts abstürzt.Den ausgelieferten Quelltext mit Ausführungs-Timeout in einer lokalen Node-/V8-Sandbox starten, etwa über
node:vmoder einen von execjs gestarteten Prozess.Beim Ablauf schreibt das Skript ein Cookie oder legt einen Wert in einem Global ab. Diesen Schreibvorgang fängst du ab und extrahierst das benötigte Token.
Die Besucherprüfung von Zhihu folgt genau diesem Muster: Ein ausgeliefertes Skript schreibt eine Besucherkennung in das Browser-Cookie. Du stellst eine DOM-Umgebung bereit, die dem Skript eine echte Seite vorgaukelt, und liest den Wert aus, sobald es fertig ist. Dabei hast du keine einzige Zeile des Algorithmus reversed. Du hast nur eine glaubwürdige Bühne gebaut, auf der das Skript sein eigenes Stück aufführen kann. Der Aufwand liegt also nicht beim „Verstehen des Algorithmus“, sondern darin, „die Umgebung real genug zu halten“. Das Skript prüft navigator.webdriver, erwartet einen bestimmten Node oder eine API-Rückgabe – und dein Shim muss genau diese Prüfungen bestehen, ohne so groß zu werden, dass er unwartbar ist. Um diesen Zielkonflikt geht es weiter unten bei der minimalen Ausführungsfläche.
Typ zwei: Eine Build-Konstante, die mit jedem Release rotiert
Der zweite Typ ist das Gegenstück dazu: Der Wert steht tatsächlich als Literal im statischen Bundle, wird aber beim Build erzeugt und mit jedem Frontend-Release ersetzt.
Der klassische Fall sind moderne Frontends mit vorregistrierten Queries statt Klartext-GraphQL. Jede Operation – etwa Timeline oder Suchergebnisse laden – ist einer beim Build erzeugten Operation- oder Query-ID zugeordnet, die im Endpoint-Pfad übertragen wird. Diese ID ist in der Build-Ausgabe auffindbar. Sobald jedoch ein neues Frontend-Release ausgerollt wird, erhält dieselbe Operation einen neuen Wert.
Statische Analyse vermittelt hier ein trügerisches „Ich hab’s“. Du findest die ID, kopierst sie, nutzt sie am selben Tag erfolgreich und hardcodest sie. Zwei Wochen später antwortet der Endpoint mit 400 – und erst dann wird klar, dass die Konstante nie wirklich konstant war. Dieser Typ ist tückischer als Typ eins, weil du glaubst, die Lösung bereits gefunden zu haben.
Die passende Strategie ist nicht Reverse Engineering – es gibt keinen Algorithmus zu reversen, sondern eine Build-Konstante. Lies den aktuellen Wert zur Request-Zeit aus der aktuellen Seite aus und cache ihn für die Sitzung. Dafür gibt es eine Reihenfolge von günstigen zu aufwendigen Optionen, die du auch genau so abarbeiten solltest:
Prüfe Ressourcen, die die Seite bereits geladen hat. Die Seite hat gerade einen Request mit dieser Kennung gesendet; der aktuelle Wert steht also bereits in dessen URL und lässt sich aus dem Resource-Record ziehen. Das ist die günstigste Variante: eine vorhandene Tatsache auslesen, statt irgendeinen Algorithmus anzufassen.
Falls das nicht möglich ist, extrahiere den Wert per Muster aus dem Skriptquelltext. Suche im aktuellen Bundle nach der Zuordnung „Operationsname zu ID“ und lies sie aus.
Erst wenn auch das scheitert, durchsuche die Modultabelle des Bundlers oder lade und parse anhand von Hinweisen weitere Bundles. Das ist die teuerste Option und gehört ans Ende.
Die Operation-IDs für Timeline und Suche von Twitter werden genau so ermittelt: Die ID wird aus einer GraphQL-Request-URL gelesen, welche die Seite bereits gesendet hat. Anschließend wird sie für die Sitzung gecacht und durchgängig verwendet.
Die systematische Variante dieses Falls – welche Wege es gibt, eine vorregistrierte Query zu ermitteln, was sie kosten und wann welcher Weg sinnvoll ist – behandelt der Artikel über Persisted Operations. Hier genügt die Einordnung in die größere Klasse laufzeitabhängiger Werte.
Der entscheidende Unterschied zwischen beiden Typen
Direkt nebeneinandergestellt wird klar, was jeweils zu tun ist.
Typ eins: Challenge | Typ zwei: dynamische Kennung | |
|---|---|---|
Wo die maßgebliche Information liegt | Wird zur Laufzeit ausgeliefert, nie im statischen Code | Steht im statischen Code, wechselt aber mit jedem Release |
Was du tust | Ausführen: echten Algorithmus laufen lassen und Seiteneffekt abgreifen | Auslesen: Konstante finden und lesen, keinen Algorithmus ausführen |
Wie ein Fehler aussieht | Umgebungs-Shim ist zu dünn, Code läuft nicht bis zum Ende | Reader trifft nach einer Bundle-Umstrukturierung nicht mehr und liefert einen veralteten oder leeren Wert |
Wer die Änderung auslöst | Der Server, jederzeit | Ein Frontend-Release im Deployment-Rhythmus |
Auf eine Zeile gebracht: Beide Typen brechen mit „einmal statisch dumpen und kopieren“. Der einzige Unterschied ist, ob du den Code tatsächlich ausführen musst. Diese Einordnung entscheidet unmittelbar, ob dein nächster Schritt eine Sandbox oder ein Extractor ist.
Warum sich Purification bei diesen Zielen oft nicht lohnt
Hier kommt häufig der Einwand: Lässt sich der Algorithmus des ausgelieferten Skripts nicht vollständig reversen? Oder die Erzeugungsregel der dynamischen Kennung vollständig rekonstruieren und nativ, losgelöst von der ursprünglichen Laufzeit, implementieren? Das wäre die Purification aus Stufe vier des vierstufigen Workflows: die sauberste Form, eine Investition für langfristige Unabhängigkeit und ohne Abhängigkeit zur Laufzeit, geeignet für CI.
Bei diesen beiden Zieltypen rechnet sich das meist nicht. Ein grobes, aber praxistaugliches Amortisationsmodell sieht so aus:
Purification verursacht einmalige Kosten C: Algorithmus reversen und per Differential Testing validieren. Danach spart sie gegenüber dem wiederholten Ausführen oder Auslesen pro Zeiteinheit s. Die Amortisationszeit beträgt ungefähr C / s.
Entscheidend ist nicht C oder s, sondern der Änderungszyklus T des Ziels – also wie häufig es sich ändert.
T < C/s: Das Ziel ändert sich, bevor sich der Aufwand amortisiert hat. Die bereinigte Implementierung passt schon wenige Tage nach dem Release nicht mehr, und du beginnst erneut. Negativer Ertrag.
T deutlich größer als C/s: Purification gewinnt klar. Eine Investition hält lange, und du solltest auf der Purification-Leiter in Richtung einer nativen Neuimplementierung aufsteigen.
Die beiden Zieltypen landen naturgemäß an unterschiedlichen Stellen:
Bei Typ eins kontrolliert der Server T, und der Zyklus kann beliebig kurz sein. Die Betreiber können die Logik des ausgelieferten Skripts jederzeit ändern, ohne dass du es vorhersagen kannst. Deshalb gehört dieser Typ fast immer auf die Seite „ausführen“. Einen Algorithmus zu bereinigen, den die Gegenseite morgen ändern kann, macht dich von deren Release-Rhythmus abhängig.
Bei Typ zwei entspricht T dem Frontend-Release-Zyklus, also Tagen bis Monaten. Je robuster dein Reader ist – mit einigen zusätzlichen Fallbacks und stabileren Ankern –, desto kleiner wird C/s. Beide Seiten können daher sinnvoll sein; hier solltest du tatsächlich rechnen.
Das ist die eigentliche Aussage des Titels: Wenn du etwas im Code nicht findest, hast du es nicht zwingend übersehen. Möglicherweise gibt es dort keinen stabilen Algorithmus, dessen Purification überhaupt sinnvoll wäre.
Die minimale Ausführungsfläche sauber begrenzen
Wenn die Entscheidung auf „ausführen“ statt „bereinigen“ gefallen ist, verschiebt sich das technische Ziel. Es geht nicht mehr um Eleganz, sondern um eine möglichst kleine, kontrollierte und eindeutig zuordenbare Ausführungsfläche. Drei Punkte sind wichtig.
Nur das notwendige Fragment ausführen. Ziehe nicht die gesamte Page-Runtime in deine Umgebung. Übergib ausschließlich den Code, von dem der Algorithmus tatsächlich abhängt, plus einen minimalen Shim. Bei dessen Größe gibt es einen Sweet Spot: klein genug, damit der Code gerade nicht mehr abstürzt. Jeder zusätzliche Stub schafft weiteren Wartungsaufwand – ändert sich eine Prüfung, musst du nachziehen. Jeder fehlende Stub führt dagegen sofort zum Abbruch. Eine vollständige Browser-Umgebung einzubinden spart keine Arbeit; am Ende wartest du nicht mehr einen Signer, sondern einen halben Browser.
Den Kontext absichern. Ein kompilierter V8-/Node-Kontext ist nicht thread-safe. Bei einer einmaligen Challenge wird ohnehin für jeden Durchlauf eine frische Sandbox geöffnet, sodass sich Aufrufe nicht gegenseitig stören. Sobald du aber einen kompilierten Kontext zur Einsparung von Startkosten wiederverwendest oder einen Parser für dynamische Kennungen zwischen Requests teilst, müssen parallele Aufrufe serialisiert werden:
class RuntimeSigner:
def __init__(self, source: Path) -> None:
self._context = execjs.get("Node").compile(source.read_text())
self._lock = threading.Lock() # V8-Kontext ist nicht thread-safe
def call(self, fn: str, *args):
with self._lock:
return self._context.call(fn, *args)
Fehler müssen klar zuordenbar sein. Das wird bei der beschaffungsorientierten Ausführung am häufigsten übersehen und spart zugleich besonders viel Zeit. Ein Fehler muss erkennen lassen, auf welcher Ebene er liegt:
Umgebung zu dünn: Der Code wirft einen
ReferenceErroroder hängt bis zum Timeout. Deinem Shim fehlt etwas, das der Code erwartet.Protokoll geändert: Der Code endet und liefert einen Wert, aber dessen Form stimmt nicht oder das JSON lässt sich nicht parsen. Das Ausgabeformat wurde geändert.
Semantik nicht erfüllt: Du erhältst einen Wert, er lässt sich parsen, ist aber unvollständig oder scheitert bei der Nutzung an der Validierung. Der Algorithmus selbst wurde verändert.
Diese drei Fälle verlangen drei völlig unterschiedliche Korrekturen: Shim ergänzen, dem Protokoll folgen oder den Algorithmus prüfen. Ein Executor, der nur pauschal „fehlgeschlagen“ meldet, zwingt dich jedes Mal zur Fehlersuche von vorn. Ein ausgereifter Challenge-Executor unterscheidet explizit zwischen Ausführungsfehler, nicht parsebarer Ausgabe, unvollständigem Ergebnis und Timeout.
Welches Modell für welchen Arbeitsschritt?
Die Rolle des Modells unterscheidet sich hier deutlich von der Fingerprinting-Arbeit. Dort identifiziert das Modell Algorithmusfamilien. Hier gibt es keinen Algorithmus zu identifizieren. Seine Hauptaufgabe ist die Architekturentscheidung „bereinigen oder ausführen“ sowie die Zuordnung von Ausführungsfehlern. Die vier Teilschritte verlangen daher ganz unterschiedliche Fähigkeiten:
Teilschritt | Benötigte Fähigkeit | Empfehlung | model id |
|---|---|---|---|
Bereinigen oder ausführen entscheiden – beide Seiten begründen | Starkes Reasoning, bereit gegen die eigene erste Antwort zu argumentieren | Claude Opus 5 |
|
Versteck der dynamischen Kennung finden: Punkt der Konstanteninjektion lokalisieren und Kandidaten im gesamten Bundle auslesen | Langer Kontext, kann das gesamte Bundle auf einmal lesen | Kimi K3 |
|
Kandidaten-Skripte im großen Umfang filtern, die die gesuchte Operation enthalten könnten | Günstig, Hunderte Aufrufe mit hoher Parallelität | Claude Sonnet 5 |
|
Bei einem Ausführungsfehler Log oder Stack lesen und die fehlerhafte Ebene bestimmen | Mittleres Reasoning, erklärt anhand eines konkreten Fehlers | GPT-5.6 Sol |
|
Besonders hervorzuheben ist die erste Zeile: In diesem Artikel ist das der eine Schritt, bei dem ein Modellwechsel das Ergebnis sichtbar verändert. Getestet wird genau die Fähigkeit, beide Seiten und gegen sich selbst zu argumentieren – dieselbe Fähigkeit wie im Abschnitt zu Gegenbelegen bei der Fingerprinting-Arbeit. Ein schwächeres Modell wählt einen Weg und sammelt anschließend Argumente dafür, ohne die Alternative ernsthaft zu prüfen. Ein starkes Reasoning-Modell führt sowohl „bereinigen“ als auch „ausführen“ konsequent zu Ende, formuliert für beide den stärksten Fall und den jeweiligen Fehlermodus und zieht erst danach einen Vergleich als Schlussfolgerung.
Verlass dich nicht auf meine Einschätzung, sondern teste den Unterschied:
Nimm ein reales Ziel, für das du die Entscheidung bereits getroffen hast, als Kontrollfall. Du weißt aus Erfahrung, ob es ausgeführt oder bereinigt werden sollte.
Gib die beobachteten Fakten – zur Laufzeit ausgelieferte Logik oder Release-Konstante, Änderungsfrequenz, Tiefe der Umgebungsabhängigkeiten – an
claude-opus-5undgpt-5.6-sol. Beide sollen ein Entscheidungs-Memo zu „bereinigen oder ausführen“ schreiben.Achte auf zwei Punkte: Erkennt das Modell den Änderungszyklus als entscheidende Variable? Falls es nur die Implementierungsschwierigkeit vergleicht, verliert es. Und sind die Bedingungen, unter denen es seine Entscheidung ändern würde, beobachtbar? „Wenn sich die Kennung beim nächsten Release nicht mehr ändert, zurück zu Purification wechseln“ ist mit einem konkreten Triggersignal brauchbar.
Eine Runde genügt, um zu sehen, welches Modell tatsächlich eine Entscheidung herleitet und welches sie nur für dich verkündet.
Das eigentliche Problem sind Wechselkosten
Vier Modelle von drei Anbietern, drei SDKs, drei Authentifizierungsschemata und drei Fehlerformate. Den Client dreimal umzuschreiben, nur um zwischen Teilschritten das Modell zu wechseln, lohnt sich nicht. Deshalb verwenden die meisten über den gesamten Prozess ein einziges Modell. Bei „bereinigen oder ausführen“ nehmen sie dann eines, das nicht gegen sich selbst argumentiert, stürzen sich in die Purification eines Ziels, das die Gegenseite morgen ändern wird, und merken erst nach einem langen Umweg, dass die Richtung von Beginn an falsch war.
AIReiter nimmt diese Schicht heraus: ein Key, eine OpenAI-kompatible Schnittstelle, alle vier Tiers dahinter. Der Wechsel erfolgt allein über das Feld model im Request-Body.
# Bereinigen oder ausführen: Der Reasoning-Tier argumentiert beide Seiten
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<beobachtete Fakten + den stärksten Fall für Purification und Ausführung begründen>"}]
}'
# Dynamische Kennung im Bundle finden: nur das Modellfeld ändern, alles andere bleibt gleich
# "model": "kimi-k3"
# Kandidaten-Skripte im großen Umfang filtern:
# "model": "claude-sonnet-5"
# Ausführungsfehler zuordnen:
# "model": "gpt-5.6-sol"
Wenn du bereits das OpenAI SDK nutzt, setze base_url auf https://aireiter.com/api/v1 und ändere nichts weiter. Mit dem Anthropic SDK verwendest du mit demselben Key POST /api/v1/messages.
Bei den Kosten konzentriert sich das Tokenvolumen dieses Workflows auf wenige Schritte: Kimi K3 liest das gesamte Frontend-Bundle, um die dynamische Kennung zu finden – einige Hunderttausend Input-Tokens pro Durchlauf. Claude Sonnet 5 filtert Kandidaten-Skripte in großer Menge, leicht Hunderte Aufrufe. Diese beiden Posten bestimmen den Großteil der Rechnung. Der 30%-Rabatt auf Claude greift genau beim Bulk-Filtering mit Sonnet und bei der Entscheidungsbegründung mit Opus. GPT zum halben Preis kommt bei der Fehlerzuordnung mit GPT-5.6 Sol zum Einsatz, und der Long-Context-Read über das gesamte Bundle mit K3 ist unter demselben Key verfügbar. Der Rabatt liegt damit auf dem tokenintensivsten Batch und dem teuersten Reasoning-Tier, nicht auf einem beliebig günstigen Modell.
Ohne Anmeldung ausprobieren: Lass zunächst einige Entscheidungs-Memos zu „bereinigen oder ausführen“ manuell erstellen, vergleiche beide Modelle bei der Erkennung des Änderungszyklus und entscheide danach, ob du es integrierst.
Fazit
Wenn statische Analyse keine Treffer liefert, sagt das nicht immer etwas über deine Fähigkeiten aus. Manchmal liegt es am Ziel selbst: Die Signatur steht nicht im Code, weil sie erst zur Laufzeit ausgeliefert wird oder sich mit jedem Release ändert.
Bei diesen beiden Typen solltest du aufhören, nach einer nicht vorhandenen Funktion zu suchen. Beim Challenge-Typ führst du sie mit minimalem Aufwand aus und baust nur eine Sandbox, die real genug für ein Ergebnis ist. Bei dynamischen Kennungen liest du den Wert zur Laufzeit aus und cache ihn für die Sitzung. Beiden Wegen geht dieselbe Einsicht voraus: Die Sicht auf einen „statischen Snapshot“ funktioniert nicht mehr.
„Bereinigen oder ausführen“ ist zudem eine reine Architekturentscheidung, die von einer Variable abhängt: Schlägt der Änderungszyklus des Ziels deine einmalige Amortisationszeit? Bei kurzlebigen Zielen, besonders bei solchen, deren Skript der Server jederzeit ausliefern kann, erzeugt erzwungene Purification einen negativen Ertrag. Übergib die Abwägung beider Seiten einem Reasoning-Tier, das gegen sich selbst argumentieren kann. Entscheide nicht aus dem Bauch heraus und lass das Modell nicht für dich bereinigen. Purification ist deterministisches Engineering; ihre Verifikation gehört ins Differential Testing, also in Stufe drei und vier des vierstufigen Workflows. Das Modell hilft hier nur bei einer Frage: Lohnt es sich überhaupt, dieses Ziel zu reversen?