Skip to content

fix: Bound decode_compressed to the length zlib actually wrote - #1623

Open
Mounika2456 wants to merge 2 commits into
brainboxdotcc:devfrom
Mounika2456:etf-compressed-length
Open

fix: Bound decode_compressed to the length zlib actually wrote#1623
Mounika2456 wants to merge 2 commits into
brainboxdotcc:devfrom
Mounika2456:etf-compressed-length

Conversation

@Mounika2456

Copy link
Copy Markdown

decode_compressed trusts the uncompressed size declared in the term header instead of the length uncompress() reports back, so a term that declares more than its zlib stream produces has the remainder decoded out of heap that was never written.

An ett_compressed term declaring 4096 bytes whose stream deflates to five (an ett_binary header announcing 4080 bytes of payload) comes back as a 4080 character string assembled from that memory; under ASAN the same input trips container-overflow in read_8_bits, since reserve() leaves the vector empty. zlibcontext::decompress already sizes its buffer and counts what inflate wrote, so this brings the term decoder in line with it. Covered by a new offline test that fails on master.

Code change checklist

  • I have ensured that all methods and functions are fully documented using doxygen style comments.
  • My code follows the coding style guide.
  • I tested that my change works before raising the PR.
  • I have ensured that I did not break any existing API calls.
  • I have not built my pull request using AI, a static analysis tool or similar without any human oversight.

@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for dpp-dev ready!

Name Link
🔨 Latest commit d7532cd
🔍 Latest deploy log https://app.netlify.com/projects/dpp-dev/deploys/6a6dbb12d71ef40008ba93e5
😎 Deploy Preview https://deploy-preview-1623--dpp-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions github-actions Bot added documentation Improvements or additions to documentation code Improvements or additions to code. labels Aug 1, 2026
@braindigitalis

Copy link
Copy Markdown
Contributor

thanks for the pr but how are you triggering this in asan?

discord doesn't ever send ett_compressed term values?

Comment thread src/unittest/test.cpp Outdated
#include <dpp/unicode_emoji.h>
#include <dpp/restrequest.h>
#include <dpp/json.h>
#include <zlib.h>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unit tests should not directly import zlib

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped it. The test now carries the deflate stream as a fixed byte array instead of compressing at runtime, so it only needs etf_parser.

Comment thread src/dpp/etf.cpp Outdated
throw dpp::parse_exception(err_etf, "ETF compressed value: decompresson error");
}
/* uncompress() was handed the rest of the buffer as its input, so the term ends there */
offset = size;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this changes outer parsing behaviour and is not required, can you please justify why this is here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, that wasn't needed for the overread and I've put it back to offset += destinationSize, so outer parsing is untouched. What's left is just the bound on the inner parse: the vector is sized rather than reserved so its elements actually exist, and size comes from what uncompress() reported writing instead of the declared header value.

@braindigitalis

Copy link
Copy Markdown
Contributor

this seems to have come verbatim from discord/erlpack depository we based our erl parser on.

https://github.com/discord/erlpack/blob/2a4c0e832f3cd4e07c92d4baec326a631ed50f59/js/decoder.h#L318

as such discord themselves have the same bug in their code and also never noticed it because their erl does not send compressed types (wouldn't make sense, because the entire stream can be zlib compressed)

@Mounika2456

Copy link
Copy Markdown
Author

Not off a live gateway, no. I built the term by hand and passed it straight to etf_parser::parse, which is what the test does now:

131, 'P', 0x00, 0x00, 0x10, 0x00, 0x78, 0x9c, 0xcb, 0x65, 0x60, 0xe0, 0xff, 0x00, 0x00, 0x03, 0x34, 0x01, 0x6d

That's an ett_compressed declaring 4096 bytes over a stream that inflates to five. On dev it comes back as a 4080 character string built out of the vector's uninitialized capacity; under ASAN it trips container-overflow in read_8_bits first, since reserve() leaves size() at 0 so every read is outside the container.

And you're right about the lineage, the reserve() and the declared-size bound both came over from erlpack's decoder.h verbatim. Agreed it isn't reachable from the gateway if Discord never emits compressed terms, so this only matters for anything running untrusted ETF through the public parser. No objection if you'd rather close it on that basis.

Comment thread src/dpp/etf.cpp
const int ret = uncompress((Bytef*)outBuffer.data(), &destinationSize, (const unsigned char*)(data + offset), (uLong)(size - offset));

offset += sourceSize;
offset += destinationSize;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this be source size? destination size is changed by zlib. we are supposed to increment offset by the compressed content size yes?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zlib's second parameter is destLen, not a source length. On entry it's the capacity of dest, on exit it's "the actual size of the uncompressed data". sourceLen is a plain uLong and input only, so uncompress() never reports how many compressed bytes it consumed.

So on dev that variable is already holding the uncompressed length in spite of its name, and offset += sourceSize is already advancing an offset into the compressed buffer by the decompressed size. The rename is the only thing that changed on that line, the arithmetic is identical to dev either way.

Your read of what it should be is right though. Getting the consumed input count needs uncompress2(), where sourceLen is in/out and comes back as the number of source bytes consumed. It only shows up for a compressed term nested inside another term, since a top level one has nothing after it to parse. I left the line alone because it's pre-existing and you'd flagged the outer parsing change as out of scope, but I can switch it to uncompress2() here if you'd rather have it fixed in one go.

@Ashthetik

Ashthetik commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Not off a live gateway, no. I built the term by hand and passed it straight to etf_parser::parse, which is what the test does now:

131, 'P', 0x00, 0x00, 0x10, 0x00, 0x78, 0x9c, 0xcb, 0x65, 0x60, 0xe0, 0xff, 0x00, 0x00, 0x03, 0x34, 0x01, 0x6d

That's an ett_compressed declaring 4096 bytes over a stream that inflates to five. On dev it comes back as a 4080 character string built out of the vector's uninitialized capacity; under ASAN it trips container-overflow in read_8_bits first, since reserve() leaves size() at 0 so every read is outside the container.

And you're right about the lineage, the reserve() and the declared-size bound both came over from erlpack's decoder.h verbatim. Agreed it isn't reachable from the gateway if Discord never emits compressed terms, so this only matters for anything running untrusted ETF through the public parser. No objection if you'd rather close it on that basis.

Hi Mounika, just popping in as an independent researcher, I’ve taken the liberty to review the PR and disclosed vulnerability. During a triage with Brain, privately, it’s believed that this is no more than an informative report that doesn’t require a patch (as mentioned early that terms are not used by Discord nor D++ -- they're provided solely for completeness of the ETF API for Erlpack).

In order for this vulnerability to be actually exploit this is issue, the API user would need to intentionally expose the ETF functions for the use of an external parser for WebSockets. As this would be outside of the scope of the project, the overall exploitability of the issue would be low due to it’s need for an intentional and targeted usage of the API outside of the bounds of the scope. To add additional clarity: if a user was to take this route, it would be an issue within the design control of the user's project and not the design control of D++.

I will note, that this does contain the potential to spray the heap for secrets in the API if it was changed with improper access controls on the user’s behalf. Although the chance for obtaining said secrets remains low due to how ASLR operates for all programs and systems.

I’ve provided a calculated CVSS 3.1 Vector for the reported vulnerability. If you have any questions, do feel free to ask and I can provide further clarification.

CVSS 3.1 Vector:
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N/E:P/RL:X/RC:R/CR:L/IR:X/AR:X/MAV:N/MAC:X/MPR:N/MUI:N/MS:U/MC:L/MI:N/MA:N

CVSS Base Score: 5.3
Impact Subscore: 1.4
Exploitability Subscore: 3.9
CVSS Temporal Score: 4.8
CVSS Environmental Score: 4.2
Modified Impact Subscore: 0.7
Overall CVSS Score: 4.2

Edit: added additional clarity

@Mounika2456

Copy link
Copy Markdown
Author

@Ashthetik that matches how I'd scope it. Reaching this needs the application to feed untrusted ETF into the parser itself, which the library never does on its own path, so informative rather than something worth an advisory. No argument on the scoring.

@braindigitalis I answered the offset question in the thread above. Short version is that the sourceSize variable on dev is already holding zlib's destination length, so offset += ... comes out to the same value either way and this PR doesn't move it. What is left is sizing the vector so its elements exist and bounding the inner parse by what uncompress() reported writing. Fine by me to close this if you'd rather leave the parser as is.

@braindigitalis

Copy link
Copy Markdown
Contributor

Hi, we arent able to merge this until youve done the CLA and ticked the boxes in the PR description

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code Improvements or additions to code. documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants