ZNCString::Base64Decode: undefined behaviour (left shift of negative) on invalid base64 #1085

Closed
opened 2026-10-05 05:02:36 +02:00 by adam · 0 comments
Owner

CString::Base64Decode: undefined behaviour on invalid base64 input

CString::Base64Decode performs (c << N) on a signed char whose value is
-1 whenever the input contains a byte that's outside the base64 alphabet.
The decode lookup table stores XX = 0xff as the sentinel for invalid bytes;
that sentinel is then read through (char) (signed on every platform ZNC
supports), making the shifts UB under C++17 (still UB pre-C++20).

Three nearby sites have the same pattern:

// src/ZNCString.cpp
1083: unsigned long CString::Base64Decode(CString& sRet) const {
...
1095:     for (i = 0, p = out; i < uLen; i++) {
1096:         c  = (char)base64_table[(unsigned char)in[i++]];
1097:         c1 = (char)base64_table[(unsigned char)in[i++]];
1098:         *p++ = char((c << 2) | ((c1 >> 4) & 0x3));            // <-- UB when c  == -1
...
1104:             c = (char)base64_table[(unsigned char)in[i]];
1105:             *p++ = char(((c1 << 4) & 0xf0) | ((c >> 2) & 0xf));// <-- UB when c1 == -1
...
1112:             *p++ = char(((c << 6) & 0xc0) |                    // <-- UB when c  == -1
1113:                         (char)base64_table[(unsigned char)in[i]]);

Reachability

Base64Decode is called pre-auth from CHTTPSock::ReadLine for the value of
an Authorization: header (src/HTTPSock.cpp:140):

138:    } else if (sName.Equals("Authorization:")) {
139:        CString sUnhashed;
140:        sLine.Token(2).Base64Decode(sUnhashed);

Any unauthenticated client can reach it. A Basic auth value containing
~, ,, *, etc. (anything mapped to the XX sentinel) triggers the UB.

In production builds without UBSan or -ftrapv, arm64/x86 just execute the
shift on the wrapped pattern and produce a garbage decoded byte, after which
authentication fails with no externally visible difference. So the practical
production impact is nil. The bug shows up as a hard abort in any build that
runs UBSan (CIFuzz, OSS-Fuzz, distros that enable -ftrapv, or anyone
running ZNC under a sanitizer for debugging).

Reproduction

Build a small standalone driver against the existing libznc.a:

clang++ -std=c++17 -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \
  -I include -I build/include -include znc/zncconfig.h \
  poc_b64.cpp build/src/libznc.a \
  -lpthread -ldl -framework CoreFoundation -L/opt/homebrew/lib -largon2 \
  -o poc_b64

poc_b64.cpp:

#include <cstdio>
#include <znc/ZNCString.h>
int main() {
    for (auto in : {"~AAA", "A~AA", "AA~A"}) {
        CString s(in), out;
        s.Base64Decode(out);
        fprintf(stderr, "input %s -> decoded %zu bytes\n", in, out.size());
    }
}

Output (UBSAN_OPTIONS=halt_on_error=0):

ZNCString.cpp:1098:24: runtime error: left shift of negative value -1
input ~AAA -> decoded 3 bytes
ZNCString.cpp:1105:30: runtime error: left shift of negative value -1
input A~AA -> decoded 3 bytes
ZNCString.cpp:1112:29: runtime error: left shift of negative value -1
input AA~A -> decoded 3 bytes

Same UB also reachable via the fuzzer driver against a complete HTTP
request, no auth needed:

printf 'GET / HTTP/1.0\r\nAuthorization: Basic AA~A\r\n\r\n' > req
./fuzz_http req
# ZNCString.cpp:1112:29: runtime error: left shift of negative value -1
#   ... CString::Base64Decode
#   ... CHTTPSock::ReadLine HTTPSock.cpp:140

Channel

Filing as a regular issue since there's no SECURITY.md yet (#1914) and the
practical impact in production builds is nil. Happy to route it differently
if you'd prefer.

Fix

Cast to unsigned char before shifting so the operand is non-negative:

c  = base64_table[(unsigned char)in[i++]];   // c is unsigned char, value 0..0xff
c1 = base64_table[(unsigned char)in[i++]];
*p++ = char(((unsigned)c << 2) | ((c1 >> 4) & 0x3));
...
*p++ = char(((unsigned)c1 << 4) | ((c >> 2) & 0xf));
...
*p++ = char(((unsigned)c << 6) | base64_table[(unsigned char)in[i]]);

Or alternatively, treat the XX sentinel as an explicit decode failure
(currently the function silently produces garbage on invalid input).

Happy to send a PR if useful.

# `CString::Base64Decode`: undefined behaviour on invalid base64 input `CString::Base64Decode` performs `(c << N)` on a signed `char` whose value is `-1` whenever the input contains a byte that's outside the base64 alphabet. The decode lookup table stores `XX = 0xff` as the sentinel for invalid bytes; that sentinel is then read through `(char)` (signed on every platform ZNC supports), making the shifts UB under C++17 (still UB pre-C++20). Three nearby sites have the same pattern: ```cpp // src/ZNCString.cpp 1083: unsigned long CString::Base64Decode(CString& sRet) const { ... 1095: for (i = 0, p = out; i < uLen; i++) { 1096: c = (char)base64_table[(unsigned char)in[i++]]; 1097: c1 = (char)base64_table[(unsigned char)in[i++]]; 1098: *p++ = char((c << 2) | ((c1 >> 4) & 0x3)); // <-- UB when c == -1 ... 1104: c = (char)base64_table[(unsigned char)in[i]]; 1105: *p++ = char(((c1 << 4) & 0xf0) | ((c >> 2) & 0xf));// <-- UB when c1 == -1 ... 1112: *p++ = char(((c << 6) & 0xc0) | // <-- UB when c == -1 1113: (char)base64_table[(unsigned char)in[i]]); ``` ## Reachability `Base64Decode` is called pre-auth from `CHTTPSock::ReadLine` for the value of an `Authorization:` header (`src/HTTPSock.cpp:140`): ```cpp 138: } else if (sName.Equals("Authorization:")) { 139: CString sUnhashed; 140: sLine.Token(2).Base64Decode(sUnhashed); ``` Any unauthenticated client can reach it. A `Basic` auth value containing `~`, `,`, `*`, etc. (anything mapped to the `XX` sentinel) triggers the UB. In production builds without UBSan or `-ftrapv`, arm64/x86 just execute the shift on the wrapped pattern and produce a garbage decoded byte, after which authentication fails with no externally visible difference. So the practical production impact is nil. The bug shows up as a hard abort in any build that runs UBSan (CIFuzz, OSS-Fuzz, distros that enable `-ftrapv`, or anyone running ZNC under a sanitizer for debugging). ## Reproduction Build a small standalone driver against the existing `libznc.a`: ```bash clang++ -std=c++17 -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 \ -I include -I build/include -include znc/zncconfig.h \ poc_b64.cpp build/src/libznc.a \ -lpthread -ldl -framework CoreFoundation -L/opt/homebrew/lib -largon2 \ -o poc_b64 ``` `poc_b64.cpp`: ```cpp #include <cstdio> #include <znc/ZNCString.h> int main() { for (auto in : {"~AAA", "A~AA", "AA~A"}) { CString s(in), out; s.Base64Decode(out); fprintf(stderr, "input %s -> decoded %zu bytes\n", in, out.size()); } } ``` Output (UBSAN_OPTIONS=halt_on_error=0): ``` ZNCString.cpp:1098:24: runtime error: left shift of negative value -1 input ~AAA -> decoded 3 bytes ZNCString.cpp:1105:30: runtime error: left shift of negative value -1 input A~AA -> decoded 3 bytes ZNCString.cpp:1112:29: runtime error: left shift of negative value -1 input AA~A -> decoded 3 bytes ``` Same UB also reachable via the fuzzer driver against a complete HTTP request, no auth needed: ```bash printf 'GET / HTTP/1.0\r\nAuthorization: Basic AA~A\r\n\r\n' > req ./fuzz_http req # ZNCString.cpp:1112:29: runtime error: left shift of negative value -1 # ... CString::Base64Decode # ... CHTTPSock::ReadLine HTTPSock.cpp:140 ``` ## Channel Filing as a regular issue since there's no SECURITY.md yet (#1914) and the practical impact in production builds is nil. Happy to route it differently if you'd prefer. ## Fix Cast to `unsigned char` before shifting so the operand is non-negative: ```cpp c = base64_table[(unsigned char)in[i++]]; // c is unsigned char, value 0..0xff c1 = base64_table[(unsigned char)in[i++]]; *p++ = char(((unsigned)c << 2) | ((c1 >> 4) & 0x3)); ... *p++ = char(((unsigned)c1 << 4) | ((c >> 2) & 0xf)); ... *p++ = char(((unsigned)c << 6) | base64_table[(unsigned char)in[i]]); ``` Or alternatively, treat the `XX` sentinel as an explicit decode failure (currently the function silently produces garbage on invalid input). Happy to send a PR if useful. <!-- github-sync-id:4326793844 -->
adam closed this issue 2026-10-05 05:02:36 +02:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: adam/znc#1085