Elementrica
03Case Studies04References05Company06News07Contact
PLENDE

159 CVEs in libraries. The bug that hurt most has no CVE number

Penetration test
159 CVEs in libraries. The bug that hurt most has no CVE number

The client gave us the repositories for two applications and one question a scanner cannot answer: which of these actually hurts us.

We ran a static analysis of the code and the dependencies. It returned 115 findings, 68 of them high severity. The worst one had no CVE number, never lit up red on any dashboard, and fit in a single line of PHP.

Here is how we got from a counter reading 159 CVEs to one method computing md5($pass), with a MITRE ATT&CK mapping for the blue team.

Context

We started where everyone starts: we ran the dependency analysis and watched the counter climb to 159. Prototype pollution, ReDoS, SSRF, cross-realm object access in the bundler. A tidy list. Three hours later we were reading the user entity line by line and found a method that returned md5($pass). No salt, no work factor, nothing.

The scope covered two parts of the system: an operator panel written in a version of Angular that shipped in 2018 and lost support in 2020, and an older admin panel built on jQuery, with a PHP backend on an MVC framework. On top of that, a payments module integrating with an external provider and an internal search engine. We got the full sources, both lock files, and permission to read everything. That happens less often than you would think, and it cuts the path to useful conclusions dramatically.

Credit where it is due. The repositories had consistent lock files, external traffic ran over TLS, the panels were separated, and the team ordered the test themselves instead of waiting for a regulation to force it. The class with md5 was not born of carelessness either. It outlived several development teams, and nobody had a reason to revisit it, because it worked.

The challenge

The SCA analysis produced a picture familiar to anyone who has run a scanner on a front-end project once. In the dependency tree, 62 packages, 60 of them with at least one known vulnerability, 159 hits, 106 unique CVEs. Taken literally, the team would be busy through the end of the year with zero certainty that any of it reduces risk.

Because most of those hits lived in development dependencies, the ones for building and testing. webpack-dev-server, karma, webpack-dev-middleware, terser, loader-utils. Code that never reaches the user's browser and never runs in production. The critical prototype pollution in loader-utils is real for a CI pipeline if that pipeline builds untrusted code. In the application the user sees, it does nothing.

A scanner has one strength and one weakness. It never gets bored and it never thinks. The dependency tree held three different versions of semver, a library whose entire job is comparing versions. It could not agree with itself. On top of that, two versions of json5, two of cross-spawn, three of ws.

$ npm ls semver          # wynik przycięty
[email protected]
├─┬ <toolchain builda>
│ └── [email protected]
├─┬ <runner testów>
│ └── [email protected]
└── [email protected]

Same story with the admin panel front end. A form-validation plugin in a version vulnerable to ReDoS was counted ten times, because minified builds and translation files sat next to the source: Dutch, Finnish, Chilean Spanish, and a few others. Each file as its own finding. A regular expression does not get more dangerous because a directory holds six localization files.

So the real question was not how many CVEs we had. It was which of these is reachable for someone who has only a browser and an account in the application.

What we did

We split the SCA output into three groups: code that runs on the user's side, code that runs on the server, and code that lives only at build time. Only after that cut did the list start to mean anything. Of the 159 hits, the user-or-server group came down to about a dozen.

Then we went by hand through everything the weak-hash-algorithm rule had flagged. Eleven hits. Ten of them sat in the payment provider's integration library, in the functions that compute the request signature. That provider requires MD5 in its older API, and there is no design decision to make there, only conformance to someone else's specification. We wrote it up as accepted risk, with a recommendation to migrate to the newer interface version, where the signature is computed with SHA-384.

The eleventh hit looked like this:

class User extends Entity
{
    protected $attributes = [
        'email' => null,
        'password' => null,
        // ... trzydzieści kilka pól adresowych i rozliczeniowych
    ];

    public static function getHashedPassword($pass)
    {
        return md5($pass);
    }
}

The whole class handles the customer's personal data: address, invoice details, billing details. At the end sits one method that turns a password into an MD5 hash with no salt and no computational cost. This is CWE-916, use of a hash with insufficient computational effort, and A02:2021 in the OWASP Top 10. Publicly available hashcat results for mode 0 on a single modern GPU reach tens of billions of hashes per second. A database of passwords like these is not encrypted after a leak, it is delayed by a few hours.

No salt has one more effect that gets less airtime. Identical passwords produce identical hashes, so without cracking anything you can see which users share a password, and which password is the most common in the database. A GROUP BY is enough.

While reading the code we also found a constant salt hardcoded in the sources and used to tag pieces of content (CWE-798), communication with the internal search engine over plain HTTP with the host and port hardcoded in the configuration, and a backend on a framework version vulnerable to CVE-2023-46240. In that version, even in production, it can render a full error report together with the stack trace. That last one is nasty precisely because it gives the attacker nothing on its own, but on the first 500 error it hands over file paths, versions, and the structure of the application.

We mapped the findings to MITRE ATT&CK so the team on the client's side would have them in the same language they write detection rules in. A caveat: this was static analysis, so the mapping shows what can be done with this, not what we did.

IDTechniqueWhat it means in this code
T1190Exploit Public-Facing ApplicationOutdated components reachable from outside, including error disclosure by the framework
T1110.002Brute Force: Password CrackingUnsalted MD5 hashes, cracked offline after any database leak
T1078Valid AccountsCracked passwords work in the application, and with password reuse in other services too
T1552.001Unsecured Credentials: Credentials In FilesSalt and connection parameters hardcoded in the repository
T1040Network SniffingQueries to the search engine over HTTP inside the network
T1499.003Endpoint DoS: Application Exhaustion FloodReDoS in validation libraries and in the project's own code

The outcome

The report ended with 115 findings, 68 high, 37 medium, and 10 low. For the first wave of fixes we pointed at four things.

Migrate password storage to Argon2id, in line with the OWASP Password Storage Cheat Sheet and NIST SP 800-63B, without forcing a reset on every user at once. A rehash on the first correct login handles it quietly:

public function verifyAndUpgrade(string $plain, string $stored, int $userId): bool
{
    if (str_starts_with($stored, '$argon2id$')) {
        return password_verify($plain, $stored);
    }

    if (!hash_equals($stored, md5($plain))) {
        return false;
    }

    $this->users->update($userId, [
        'password' => password_hash($plain, PASSWORD_ARGON2ID),
    ]);

    return true;
}

Update the backend framework to a version with the error-disclosure fix, and force display_errors off in the production configuration, hard, regardless of version.

Move the salt and the connection parameters out of the repository into environment variables or a secrets manager, and rotate the value that already made it into git history. Changing the file is not enough, because the old value stays in the commits.

Update the front-end libraries reachable by the browser, the form validation and the UI library, and remove from the assets directory the localization files the application does not load. The rest of the SCA hits went to the build-process group, due at the next larger update and under one condition: building untrusted code in the same runner moves them back to the top of the list.

Honestly, static analysis will not tell you whether the admin panel enforces proper function-level access control, whether session tokens are invalidated on a password change, or whether the payments module can be gamed at the order-logic level. Those are questions for a dynamic test, and that is how we wrote them into the recommendations for the next stage.

If you have a function in your code that hashes passwords and nobody has touched it in years, check it now, before you start sorting the scanner report by CVSS. The most dangerous vulnerability may carry no CVE number at all.

How many hits in your last SCA report has anyone actually read?

Book a call with a consultant. Together we will scope a code and dependency review (SAST/SCA), the real risks, and what a review like this could look like in your environment.

Previous
We uploaded a package. We got root.

All case studies are anonymized by sector, without names, dates or any data that could identify the client, in line with confidentiality. We never publish real vulnerabilities or client technical data.