You are a security analyst tasked with analyzing a Python script that is suspected to be a malicious stealer.
The sample was a single plaintext Python stealer, so the whole analysis was a static read of the source with nothing executed. The script decrypts the browser’s master key through the Windows DPAPI and uses it with AES to open stored secrets, then harvests Discord tokens by regex from local storage files. It also looks up the victim’s public IP, and it fetches its exfiltration webhook at runtime from an externally hosted page rather than hardcoding it, parsing the HTML with a spoofed browser User-Agent. Stolen data leaves as a Discord webhook post that carries a custom avatar image and the author’s signature.
unzip -P. Contains one file, stealer.pycat and grep. Never run itFormat note: Each task shows a masked format hint. For the User-Agent, URL and regex answers the mask matches the literal string in the source, so copy it exactly as it appears.
Extract the archive and read the header of stealer.py. The author signs the file with a credit line in a comment and the same handle comes back in the embedded webhook payload, so a grep for Made by lands on it directly. The format mask is six characters.
unzip -P infected danger.zip -d work/sample
grep -n "Made by" work/sample/stealer.py
Read the decrypt() routine in stealer.py. The browser’s Local State key is stored DPAPI-protected, so the script calls the Windows API that unwraps it for the current user. That call is the master-key decryption step, and the 18-character mask fits the Windows API name.
cat work/sample/stealer.py
From Q1 the master key is the AES key, so look at where decrypt() builds the cipher object. The AES.new call passes a mode constant, which is the authenticated mode used by Chromium for its encrypted values. The three-character mask matches the mode name.
grep -n "AES" work/sample/stealer.py
Follow the call graph from the main routine. One function walks the browser and Discord storage paths and returns the harvested values, and its name is a verb and noun joined by an underscore (mask ***_*****). That is the collection entry point that feeds the exfiltration step.
grep -n "def " work/sample/stealer.py
Inside the Q6 function the script scans the leveldb and log files and runs re.findall on the contents. Encrypted tokens start with a fixed prefix followed by base64 text, and the pattern also carries odd bracket and anchor characters. The mask ***********:[^.*\['(.*)'\].*$][^\"]* already shows the tail, so copy the whole pattern from the findall call.
grep -n "findall" work/sample/stealer.py
Tip: The pattern looks mangled because it is a character class, not a real capture group. Copy it byte for byte rather than retyping it.
The payload sent to the webhook includes the victim’s public IP, so a small helper requests it from an external service. It is the five-character function defined near the top of the script.
grep -n "def " work/sample/stealer.py
The script does not hardcode its webhook. The get_webhook_url routine requests a page on an externally hosted site and reads the webhook out of it. The hooked URL is the address of that page, a randomly named subdomain on a free static-hosting platform (mask *****://******************.*******.***).
grep -n -A8 "def get_webhook_url" work/sample/stealer.py
From Q5 the page is fetched and parsed with BeautifulSoup. The webhook sits in a <p> element selected with soup.find("p", id=...), so the ID passed there is the answer. It is a nine-character random string, which means the operator can rotate the webhook by editing the page and never touch the deployed script.
grep -n "soup.find" work/sample/stealer.py
The request in get_webhook_url sends a header dictionary (headers2) with a hardcoded User-Agent. It is an old Chrome 23 string on Linux, which does not match the Windows victims the rest of the script targets. That mismatch is a cheap network indicator for the page fetch.
grep -n -i "user-agent" work/sample/stealer.py
Find the JSON payload that the script posts to the webhook. Besides the content, it sets a custom avatar_url so the messages show up in the Discord channel with the author’s branding. The value is a Discord CDN attachment link ending in an image filename.
grep -n "avatar_url" work/sample/stealer.py
| Phase | Action |
|---|---|
| Resource Development | Operator ships a single Python stealer signed with a handle |
| Command and Control | Script fetches an externally hosted page with a spoofed User-Agent and parses the webhook from a <p> element |
| Discovery | getip looks up the victim’s public IP |
| Credential Access | Browser master key unwrapped with DPAPI, then AES-GCM decryption of stored secrets |
| Credential Access | Discord tokens harvested from local files with a regex |
| Exfiltration | Data posted to the Discord webhook with a custom avatar |
| Type | Value |
|---|---|
| URL (webhook resolver) | hxxps[://]fhrkvjbdhfdobdkjvh[.]netlify[.]app |
| HTML element ID | djhjvsdbk |
| User-Agent | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.64 Safari/537.11 |
| URL (avatar) | hxxps[://]cdn[.]discordapp[.]com/attachments/826581697436581919/982374264604864572/atio[.]jpg |
| Function names | decrypt, get_token, getip, get_webhook_url |
| Author handle | Astraa |
| Technique | ID | Description |
|---|---|---|
| Credentials from Web Browsers | T1555.003 | Browser master key unwrapped with CryptUnprotectData, secrets decrypted with AES-GCM |
| Steal Application Access Token | T1528 | Discord tokens pulled from local storage by regex |
| System Network Configuration Discovery | T1016 | getip retrieves the victim’s public IP |
| Web Service: Dead Drop Resolver | T1102.001 | Webhook fetched at runtime from a page on a static hosting platform |
| Application Layer Protocol: Web Protocols | T1071.001 | HTTP fetch with a spoofed Linux Chrome User-Agent |
| Exfiltration Over Web Service: Exfiltration Over Webhook | T1567.004 | Stolen data posted to a Discord webhook |
Stealers that resolve their webhook at runtime defeat static blocklists. The Discord webhook is not in the script, only the hosting page is. Block or alert on requests to throwaway subdomains on free static-hosting platforms from non-browser processes, and treat the page URL as the pivot to find the webhook.
A User-Agent that does not match the host OS is a free detection. The script claims Chrome 23 on Linux while running on Windows desktops. Proxy logs with a User-Agent versus endpoint OS check will surface it.
Discord webhook traffic from a Python process is not normal. Alert on discord.com/api/webhooks and cdn.discordapp.com connections from python.exe or from unsigned binaries, particularly right after a read of browser profile directories.
DPAPI-protected browser keys fall to any code running as the user. CryptUnprotectData needs no privilege beyond the logged-in account, so the control is keeping untrusted scripts from running at all and moving credentials out of browser stores and into a managed vault.
This stealer is small and unobfuscated, yet it covers the whole theft chain: unwrap the browser key, harvest tokens, geolocate the victim, and exfiltrate over a trusted chat service. The one design choice that matters for defenders is the externally hosted webhook, because it turns one hosting-page URL into the thread that unravels the operation.