In this blog we’re breaking down each CVE separately, including the root cause, technical overview and patch details.
Read our analysis of these 4 critical CVEs
Netty CVE-2026-75595
Root Cause
A TLS record begins with a 5-byte header, and the handshake message inside it begins with its own 4-byte header: one byte of handshake type, three bytes of length. `SslClientHelloHandler` has to read both before it knows how much ClientHello to expect, and the standard permits a handshake message to be split across several records, so it must be prepared for the header to arrive incomplete.
The guard for that case, as quoted in the advisory:
if (handshakeLength == -1) {
if (readerIndex + 4 > endOffset) {
// Need more data to read HandshakeType and handshakeLength (4 bytes)
return;
}Now count what the next lines actually read. The handshake type comes from `readerIndex + SSL_RECORD_HEADER_LENGTH`, which is `readerIndex + 5`. The three-byte length comes from `readerIndex + SSL_RECORD_HEADER_LENGTH + 1`, so bytes six, seven and eight past `readerIndex`. Completing that read requires nine readable bytes. The guard requires four.
The comment is right about the intent and the expression is five bytes short of it, because the reads include the record header and the check does not. Both lines are individually sensible; they simply disagree about whether `readerIndex` is before or after the record header.
The consequence needs no unusual input, only a legal one. A first record with a one to three byte payload passes the guard and then addresses past the readable region, raising an `IndexOutOfBoundsException`. What catches it is not specific to this case:
} catch (Exception e) {
// unexpected encoding, ignore sni and use default
if (logger.isDebugEnabled()) {
logger.debug("Unexpected client hello packet: " + ByteBufUtil.hexDump(in), e);
}
select(ctx, null);
}`select(ctx, null)` is the default `SslContext`. So a handshake the parser could not read is not rejected and not deferred; it is granted the fallback, and the comment says so plainly. In a deployment where the fallback is the permissive one, an exception in a bounds check has become an authentication decision.
There was a second defect in the same method with the same effect. `handshakeLength` was a local variable, reset to `-1` on every invocation of `decode`, so partial parsing progress could not survive across reads even when no exception occurred.
The Patch
The bounds check is corrected to include the record header, and inverted so the parse proceeds only when the full header is present:
- if (readerIndex + 4 > endOffset) {
- // Need more data to read HandshakeType and handshakeLength (4 bytes)
- return;
+ if (handshakeBuffer == null &&
+ readerIndex + SslUtils.SSL_RECORD_HEADER_LENGTH + 4 <= endOffset) {
An incomplete header now falls through to the aggregation path, which buffers record payloads until the whole ClientHello has arrived, rather than reaching a read it cannot satisfy. The parser’s state moves out of local variables and onto the handler:
+ private int aggregatedBytes;
+ private int handshakeLength = -1;
with `handshakeLength` reset when the handshake buffer is released, so a fragmented ClientHello is reassembled across reads instead of being reparsed from the start each time.
One thing the patch does not change is the exception handler. `select(ctx, null)` on a parse failure remains, at debug logging, with the same comment. The fix removes this route to it, and the advisory names the underlying design as the reason the bug mattered: fallback to default on parse failure is a problem when per-SNI selection is the sole gate.
GitPython CVE-2026-78676
Root Cause
GitPython’s reader is correct. `_read()` supports quoted values that continue across physical lines, and `string_decode()` applies `unicode_escape` so that a literal `\n` sequence inside such a value becomes a newline in the resulting Python string. Given this file:
[core]
zzz = "A\nhooksPath = ../evil-hooks\
"both `git config –get core.zzz` and GitPython return the string `A\nhooksPath = ../evil-hooks`, and `git config –get core.hooksPath` returns nothing. The value is one option, and it is inert.
The writer is where the two implementations part company:
fp.write(("\t%s = %s\n" % (key, self._value_to_string(v).replace("\n", "\n\t"))).encode(defenc))The embedded newline is emitted as a real newline, followed by a tab, unquoted and with no continuation backslash. That encoding assumes indentation continues a value. Git’s rule is that a value continues only when the previous physical line ends in a literal backslash immediately before the newline, so what git reads back is not one value spanning two lines. It is two options:
[core]
zzz = A
hooksPath = ../evil-hooks
`core.hooksPath` now exists, created by GitPython, in a section it inherited from wherever the dormant value was sitting.
What makes this more than an encoding bug is that GitPython already had a guard for exactly this class of problem, and the writer did not use it. Four earlier config-injection advisories were closed by adding `UNSAFE_CONFIG_CHARS_RE`, which matches `[\r\n\x00]`, and routing the argument-taking entry points through checks built on it. Version 3.1.58 therefore contains two serializers:
ef _value_to_string(self, value) -> str: # line 892
def _value_to_string_safe(self, value) -> str: # line 897, raises on UNSAFE_CONFIG_CHARS_RE`_value_to_string_safe` is called from `set`, `set_value` and `add_value`, and `_assure_config_name_safe` guards section and option names. `write_section`, the function that actually produces the file, called the plain one. The hardening sat on the arguments the caller supplies and never on the values already resident in `_sections`, which is where anything parsed from disk lives.
So the exploit needs no bad input at all. It needs a legitimate file, a correct read, and one flush.
The Patch
Five lines in `write_section`:
fp.write(("\t%s = %s\n" % (key, self._value_to_string(v).replace("\n", "\n\t"))).encode(defenc))
+ value = self._value_to_string(v)
+ if any(char in value for char in '\n\t\b\\"'):
+ value = value.replace("\\", "\\\\").replace('"', '\\"')
+ value = '"%s\\\n"' % value.replace("\n", "\\n").replace("\t", "\\t").replace("\b", "\\b")
+ fp.write(("\t%s = %s\n" % (key, value)).encode(defenc))
A value containing any of newline, tab, backspace, backslash or double quote is now written the way git writes it: backslash and quote escaped first, then the control characters converted to their two-character escapes, then the whole thing wrapped in quotes with a trailing continuation backslash. The ordering matters, since escaping the backslash after introducing new ones would double them.
Two details are worth noting about how this was fixed. The character set the writer acts on is wider than the guard’s `[\r\n\x00]`, because the goal here is correct encoding rather than rejection, and tab, backspace and quote all need escaping to survive a round trip. And the commit pins its behavioural baseline to git itself, citing `config.c` `parse_value()` and `write_pair()` at a specific upstream commit as the reference for escaping embedded LF rather than emitting a physical line. For a bug that exists entirely in the gap between two implementations of one format, checking the other implementation’s source is the right way to establish what correct means.
The regression test verifies the outcome with both readers. It starts from the inert multi-line value, performs the unrelated `set_value(“user”, “name”, …)`, then asserts that GitPython still reads one value, that `core.hooksPath` has no option, and that a real `git config –get core.hooksPath` subprocess exits non-zero.
Next.js GHSA-2xp9-vwfh-vxw4
Root Cause
The endpoint sniffs the fetched bytes and ignores the upstream `Content-Type`. Files whose type appears in `BYPASS_TYPES` are returned untouched; everything else is decoded:
const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC]HEIC is present, AVIF is not. Both are the same ISOBMFF container with different codec payloads, and libvips decodes both through one loader class backed by libheif. So the entry that looks like a decision about HEIF-family risk gated on a label the attacker picks freely, while the other label reaching the identical decoder went unlisted. A second gate, a libvips loader allowlist in `getSharp`, kept that decoder reachable on purpose:
_sharp.block({ operation: ['VipsForeignLoad'] })
_sharp.unblock({
operation: [
'VipsForeignLoadHeif', // avif
...On the 15.5.x line there was no `block`/`unblock` pair at all. Everything else guarding the path is a resource control: pixel caps, a 7 second timeout, SSRF checks on the fetch. A few kilobytes that corrupt the heap in milliseconds pass all of them.
Inside libheif, four behaviours align. A nested `iden` item is resolved by fully decoding the referenced item, which attaches its Alpha, after which the outer layer attaches a second one. `transfer_channel_from_image_as()` accepts the duplicate, carrying the check that would stop it as a comment, `// TODO: check that dst_channel does not exist yet`. `find_storage_for_channel()` returns only the first match, so the 8-bit plane answers every query while the 10-bit duplicate stays invisible. Then `scale_nearest_neighbor()` sizes its allocation at one byte per sample and iterates every plane, writing the duplicate through a `uint16_t*` at two bytes per sample. Four places a check belonged, none had one.
The Patch
Three same-day commits, one per release line. AVIF joins the bypass list and the loader leaves the allowlist:
-const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC]
+const BYPASS_TYPES = [SVG, ICO, ICNS, BMP, JXL, HEIC, AVIF]
- 'VipsForeignLoadHeif', // avifTwo independent barriers, either alone sufficient. On 15.5.x the second is an addition rather than a deletion, because that line had no allowlist to edit. The blur path is closed alongside the request path, since generating a `blurDataURL` decodes through the same call.
Neither patched release bumps sharp, so both still resolve a vulnerable libheif. Removing reachability rather than waiting on the dependency is what made a same-day release possible on two lines. A week later, 15.5.25 and 16.3.4 restored AVIF gated on a runtime read of `sharp.versions.heif`, defaulting closed when the version cannot be read, because Node 18 installs resolve the vulnerable sharp 0.34 line whatever the declared range prefers.
Next.js CVE-2026-75604
Root Cause
Two pieces combine. The first is the escaping applied to route segments:
// escape delimiters used by path-to-regexp
export default function escapePathDelimiters(
segment: string,
escapeEncoded?: boolean
): string {
return segment.replace(
new RegExp(`([/#?]${escapeEncoded ? '|%(2f|23|3f|5c)' : ''})`, 'gi'),
(char: string) => encodeURIComponent(char)
)
}Read the two halves of that character class against each other. The raw set is `/`, `#`, `?`. The encoded set is `%2f`, `%23`, `%3f` and `%5c`, which are the same three plus the backslash. The percent-encoded backslash was considered dangerous enough to escape. The literal backslash was not in the raw set.
The comment above the function explains how that happens: its stated job is escaping delimiters for path-to-regexp, the routing library. It is a router concern that later became the only thing standing between a URL segment and a filesystem path.
The second piece is the sink, where the cache path was assembled with no containment check:
private getFilePath(pathname: string, kind: IncrementalCacheKind): string {
switch (kind) {
case IncrementalCacheKind.FETCH:
return path.join(this.serverDistDir, '..', 'cache', 'fetch-cache', pathname)
case IncrementalCacheKind.PAGES:
return path.join(this.serverDistDir, 'pages', pathname)
...`path.join` resolves `..` segments as it goes, and on Windows it resolves them across backslashes too. So a `pathname` of `..\..\server-reference-manifest` produces a path above `serverDistDir`, and the function returns it without comparing the result to where it was supposed to land. Every caller trusts it: the cache `get` path reads the file and returns its contents as the cached response, and the cache `set` path appends rendered HTML, RSC payloads, route bodies and metadata JSON to it.
That platform split is the whole reason this sat unnoticed. On POSIX, `path.join` leaves a backslash alone because it is a legal filename character, so the same request produces a file with an odd name inside the cache directory and nothing escapes. The identical code is a traversal on Windows.
The Patch
Two same-day commits, one per release line, titled as a fix for cache misses with backslashes in segments on Windows. The escape set gains the missing character:
- new RegExp(`([/#?]${escapeEncoded ? '|%(2f|23|3f|5c)' : ''})`, 'gi'),
+ new RegExp(`([/#?\\\\]${escapeEncoded ? '|%(2f|23|3f|5c)' : ''})`, 'gi'),More importantly, the sink stops trusting its input. `getFilePath` is restructured so each cache kind resolves a root directory, the join happens once, and the result is checked against that root before being returned:
+ const filePath = path.join(rootDir, key)
+ if (!(filePath.startsWith(rootDir + path.sep) || filePath === rootDir)) {
+ throw new Error(`Invalid file path: ${filePath}`)
+ }
+
+ return filePathThat is the fix that matters. The escape list is still a list and can still be short by one character, but a path that leaves the cache directory now throws regardless of which character got it out, for reads and writes and every cache kind at once. The commits also add a regression test and, notably, wire it into the Windows end-to-end job in CI.