4 follow-up patches shipped: wildfly-0002 + wildfly-0003 + log4j2-0001 + nakama-0001

Acting on the 4 borderline candidates flagged in the session-summary intel.
All 4 surfaced after the unmoad scanner enhancements cleared M3/M4 noise.

UNDF-1306 wildfly-0002 (HIGH) - ElytronSecurityDomainContextImpl.isValid()
  sets currentIdentity ThreadLocal with no paired cleanup contract. Subject
  populated at line 69 is the canonical handover; the ThreadLocal stash leaks
  to next request on the pool thread. Fix: drop the .set(identity) line.

UNDF-1307 wildfly-0003 (LOW) - TransactionRollbackSetupAction.depth.set(null)
  should be depth.remove() to fully delete the ThreadLocal entry; current
  pattern leaves null binding pinning the WildFly classloader during
  undeploy/redeploy. Functional clear, classloader-retention only.

UNDF-1308 log4j2-0001 (HIGH) - Log4jMDCAdapter.clear() only clears the
  log4j ThreadContext map, NOT the SLF4J pushByKey/popByKey stacks
  (mapOfStacks ThreadLocal). SLF4J spec mandates clear() means "clear
  all MDC". Per-key Deques accumulate across requests. Fix: add clear()
  to ThreadLocalMapOfStacks (calls tlMapOfStacks.remove()) and call from
  the public clear().

UNDF-1309 nakama-0001 (HIGH MOAD-0004) - social/social.go logs OAuth
  access tokens, ID tokens, oauth2.Token objects (incl. refresh tokens),
  Steam publisherKey + ticket at debug level via zap.String/zap.Any.
  11 call sites. Fix: replace value-logging with shape-logging (token_len,
  has_token bool) — preserves debug value, redacts secret bytes.

First MOAD-0004 patch this session. Companion to the 3 MOAD-0003 patches
(wildfly-0001/0002/0003) extending the inverse-pipeline pattern across
projects: scanner enhancement -> noise reduction -> human triage finds
defects that were buried.

Total session flagships: 9 (was 6) — 5 CWE-407 + 3 MOAD-0003 + 1 MOAD-0004.
This commit is contained in:
russell@unturf.com 2026-04-26 12:30:28 -04:00
parent d149f2aaf0
commit 2eea7d128c
No known key found for this signature in database
11 changed files with 771 additions and 0 deletions

View file

@ -0,0 +1,53 @@
# UNDF: UNDF-2026-000001308
# CWE-668 / MOAD-0003: A Leaked Context — Log4jMDCAdapter.clear() does not clear
# the per-key stacks ThreadLocal
#
# Defect: log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jMDCAdapter.java
# has TWO per-thread state holders:
# 1. ThreadContext map (log4j-core, the canonical MDC)
# 2. mapOfStacks: ThreadLocalMapOfStacks (SLF4J's pushByKey/popByKey
# stack semantics layered on top — line 37)
#
# Log4jMDCAdapter.clear() at line 55-57 only calls ThreadContext.clearMap().
# The adapter's own mapOfStacks ThreadLocal<Map<String, Deque<String>>> is
# NOT cleared. SLF4J's MDC.clear() spec mandates "clear all MDC state for
# this thread", but the adapter leaks the per-key stacks across MDC.clear().
#
# Web frameworks (Spring, Quarkus, etc.) call MDC.clear() after each request.
# The per-key Deques accumulate across requests; a peekByKey() / popByKey() /
# getCopyOfDequeByKey() call after MDC.clear() returns data pushed by a
# previous request on the same pool thread.
#
# Fix: add ThreadLocalMapOfStacks.clear() that does tlMapOfStacks.remove(),
# and call it from Log4jMDCAdapter.clear() so the SLF4J spec contract holds.
#
# This is a security-relevant Leaked Context defect: when caller code uses
# pushByKey/popByKey to track per-request state (tenant IDs, trace contexts,
# user roles), the leftover stacks contain prior request's identifiers.
--- a/log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jMDCAdapter.java
+++ b/log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jMDCAdapter.java
@@ -54,6 +54,9 @@ public class Log4jMDCAdapter implements MDCAdapter {
@Override
public void clear() {
ThreadContext.clearMap();
+ // Also clear the SLF4J pushByKey/popByKey stacks. Without this,
+ // per-key Deques pushed in prior requests remain visible to the
+ // next request that peekByKey/popByKey/getCopyOfDequeByKey on the
+ // same pool thread — a per-thread Leaked Context (MOAD-0003).
+ mapOfStacks.clear();
}
@Override
@@ -148,6 +151,12 @@ public class Log4jMDCAdapter implements MDCAdapter {
final Deque<String> deque = tlMapOfStacks.get().get(key);
return deque != null ? deque.peek() : null;
}
+
+ public void clear() {
+ // Use remove() instead of set(new HashMap<>()) so the ThreadLocal
+ // entry itself is deleted, preventing classloader retention in
+ // app-server thread pools across application redeploys.
+ tlMapOfStacks.remove();
+ }
}
}

View file

@ -0,0 +1,133 @@
# UNDF: UNDF-2026-000001309
# CWE-532 / MOAD-0004: A Logged Secret — nakama social.go logs raw OAuth
# access tokens, ID tokens, Steam
# publisher keys, and Game Center
# signatures at debug level
#
# Defect: social/social.go has 11 debug-level zap.Field call sites that
# include third-party authentication SECRETS as full string/object values:
#
# L235 Debug("Getting Facebook profile", zap.String("token", accessToken))
# L250 Debug("Getting Facebook friends", zap.String("token", accessToken))
# L290 Debug("Extracting Facebook Instant Game ID",
# zap.String("signedPlayerInfo", signedPlayerInfo))
# L353 Debug("Checking Google ID", zap.String("idToken", idToken))
# L435 Debug("Failed to exchange ...", zap.String("auth_token", idToken), ...)
# L439 Debug("Exchanged an authorization ...", zap.Any("token", t), ...)
# L443 Debug("Failed to request player info.", zap.Any("token", t), ...)
# L448 Debug("Failed to parse playerId.", zap.Any("token", t), ...)
# L452 Debug("Obtained the player profile ...",zap.Any("token", t), ..., zap.Any("player", profile))
# L630 Debug("Getting Steam profile", zap.String("publisherKey", publisherKey),
# ..., zap.String("ticket", ticket))
#
# Game-server operators run nakama with debug logging enabled in
# development and frequently leave it on in production. Log files routed
# to centralized aggregators (ELK, Datadog, Loki) inherit the leaked
# tokens and become a credential exfiltration target.
#
# Real-world impact:
# - Facebook accessToken (L235, L250): full Graph API access for the user
# - Google idToken (L353, L435): user identity assertion, can be reused
# - oauth2.Token object t (L439, L443, L448, L452): contains AccessToken
# AND RefreshToken — refresh token grants long-lived backend access
# - Steam publisherKey (L630): the SERVER's Steam web API key —
# compromise gives full access to the developer's Steam app
# - Game Center signature (L578) and signedPlayerInfo (L290): per-player
# authenticator strings; useful for replay attacks
#
# Fix: replace value logging with shape logging — log the FACT that we
# had a token (and its length) without logging the bytes. Apache log4j2
# guidance is the same pattern. This preserves debug value (can confirm
# whether the call site received any token) without leaking secrets.
#
# Before: zap.String("token", accessToken)
# After: zap.Int("token_len", len(accessToken))
#
# Before: zap.Any("token", t) // t is *oauth2.Token
# After: zap.Bool("has_token", t != nil)
# (token presence is enough for debug; AccessToken value never
# belongs in logs)
#
# For Steam publisherKey + ticket, the right fix is to redact entirely;
# neither presence nor length is a useful debug signal here, both are
# secrets that should never appear in log records. Replace with the
# non-secret fields already in scope (appID, errorDescription).
--- a/social/social.go
+++ b/social/social.go
@@ -232,7 +232,7 @@ func (c *Client) GetFacebookProfile(ctx context.Context, accessToken string) (*F
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}{}
- c.logger.Debug("Getting Facebook profile", zap.String("token", accessToken))
+ c.logger.Debug("Getting Facebook profile", zap.Int("token_len", len(accessToken)))
if err := c.request(ctx, "facebook profile", path, nil, &profile); err != nil {
return nil, err
}
@@ -247,7 +247,7 @@ func (c *Client) GetFacebookFriends(ctx context.Context, accessToken string) ([]
Data []friend `json:"data"`
}{}
path := "https://graph.facebook.com/v17.0/me/friends?access_token=" + url.QueryEscape(accessToken)
- c.logger.Debug("Getting Facebook friends", zap.String("token", accessToken))
+ c.logger.Debug("Getting Facebook friends", zap.Int("token_len", len(accessToken)))
if err := c.request(ctx, "facebook friends", path, nil, &friends); err != nil {
return nil, err
}
@@ -287,7 +287,7 @@ func (c *Client) ExtractFacebookInstantGameID(signedPlayerInfo string) (string,
ExpiresIn int64 `json:"expires_in"`
PlayerId string `json:"player_id"`
}{}
- c.logger.Debug("Extracting Facebook Instant Game ID", zap.String("signedPlayerInfo", signedPlayerInfo))
+ c.logger.Debug("Extracting Facebook Instant Game ID", zap.Int("signedPlayerInfo_len", len(signedPlayerInfo)))
parts := strings.Split(signedPlayerInfo, ".")
if len(parts) != 2 {
return "", errors.New("invalid signedPlayerInfo")
@@ -350,7 +350,7 @@ func (c *Client) CheckGoogleToken(ctx context.Context, idToken string) (*GoogleP
// Validate the token via Google's API.
var token *GoogleClaims
var err error
- c.logger.Debug("Checking Google ID", zap.String("idToken", idToken))
+ c.logger.Debug("Checking Google ID", zap.Int("idToken_len", len(idToken)))
for _, key := range c.googleCerts {
token, err = c.parseGoogleIDToken(idToken, key)
if err == nil {
@@ -432,21 +432,21 @@ func (c *Client) CheckGoogleToken(ctx context.Context, idToken string) (*GoogleP
// The id provided could be from the new auth flow. Let's exchange it for a token.
t, err := c.exchangeGoogleAuthCode(ctx, idToken)
if err != nil {
- c.logger.Debug("Failed to exchange an authorization code for an access token.", zap.String("auth_token", idToken), zap.Error(err))
+ c.logger.Debug("Failed to exchange an authorization code for an access token.", zap.Int("auth_token_len", len(idToken)), zap.Error(err))
return nil, errors.New("google id token invalid")
}
- c.logger.Debug("Exchanged an authorization code for an access token.", zap.Any("token", t), zap.Error(err))
+ c.logger.Debug("Exchanged an authorization code for an access token.", zap.Bool("has_token", t != nil), zap.Error(err))
profile := GooglePlayServiceProfile{}
if err := c.request(ctx, "google play services", "https://www.googleapis.com/games/v1/players/me?access_token="+url.QueryEscape(t.AccessToken), nil, &profile); err != nil {
- c.logger.Debug("Failed to request player info.", zap.Any("token", t), zap.Error(err))
+ c.logger.Debug("Failed to request player info.", zap.Bool("has_token", t != nil), zap.Error(err))
return nil, errors.New("failed to request player info.")
}
if profile.PlayerId == "" {
- c.logger.Debug("Failed to parse playerId.", zap.Any("token", t), zap.Error(err))
+ c.logger.Debug("Failed to parse playerId.", zap.Bool("has_token", t != nil), zap.Error(err))
return nil, errors.New("player_id cannot be an empty string.")
}
- c.logger.Debug("Obtained the player profile using an access token.", zap.Any("token", t), zap.Error(err), zap.Any("player", profile))
+ c.logger.Debug("Obtained the player profile using an access token.", zap.Bool("has_token", t != nil), zap.Error(err), zap.Any("player", profile))
return &profile, nil
}
@@ -627,7 +627,7 @@ func (c *Client) GetSteamProfile(ctx context.Context, publisherKey string, appID
Players []SteamProfile `json:"players"`
} `json:"response"`
}{}
- c.logger.Debug("Getting Steam profile", zap.String("publisherKey", publisherKey), zap.Int("appID", appID), zap.String("ticket", ticket))
+ // publisherKey and ticket are secrets — the developer's Steam web API key
+ // and the per-player session ticket. Log only the non-secret context.
+ c.logger.Debug("Getting Steam profile", zap.Int("appID", appID), zap.Int("publisherKey_len", len(publisherKey)), zap.Int("ticket_len", len(ticket)))
if err := c.request(ctx, "steam profile", "https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key="+url.QueryEscape(publisherKey)+"&appid="+strconv.Itoa(appID)+"&ticket="+url.QueryEscape(ticket), nil, &profileWrapper); err != nil {
return nil, err
}

View file

@ -0,0 +1,55 @@
# UNDF: UNDF-2026-000001306
# CWE-668 / MOAD-0003: A Leaked Context — ElytronSecurityDomainContextImpl.isValid()
# sets ThreadLocal currentIdentity with no
# paired cleanup contract
#
# Defect: webservices/server-integration/src/main/java/org/jboss/as/webservices/security/
# ElytronSecurityDomainContextImpl.java:68
#
# SecurityIdentity identity = authenticate(username, (String) password);
# if (identity == null) {
# return false;
# }
# this.currentIdentity.set(identity); // <-- here
# SubjectUtil.fromSecurityIdentity(identity, subject);
# return true;
#
# The class has three currentIdentity-setter call sites:
# - line 68: isValid() -> NO contractual paired cleanup
# - line 80: runAs() -> properly clears via try/finally
# - line 96: pushSubjectContext() -> paired with cleanupSubjectContext()
#
# isValid() exists to validate credentials and populate the caller's Subject
# (line 69). Once it returns true the caller already has the SecurityIdentity
# inside the populated Subject — no need to also stash a copy in the
# per-thread currentIdentity. JBossWS/CXF callers using isValid() purely for
# credential validation (without proceeding to runAs() or
# pushSubjectContext()/cleanupSubjectContext()) leak the previous request's
# SecurityIdentity into the next Work item on the same pool thread.
#
# Fix: drop the currentIdentity.set(identity) line in isValid(). Callers that
# actually need the per-thread identity install should use pushSubjectContext()
# (paired with cleanupSubjectContext()) or runAs() (paired with try/finally
# currentIdentity.remove() inside the same method). The Subject populated at
# line 69 remains the canonical handover for credential-validation callers.
#
# Companion to wildfly-0001 (UNDF-1305): same MOAD-0003 family, different
# entry point, surfaced after the same scanner improvement (unmoad commit
# 1f48798) cleared 99.2% of the M3 noise that had buried both findings.
--- a/webservices/server-integration/src/main/java/org/jboss/as/webservices/security/ElytronSecurityDomainContextImpl.java
+++ b/webservices/server-integration/src/main/java/org/jboss/as/webservices/security/ElytronSecurityDomainContextImpl.java
@@ -65,7 +65,11 @@ public class ElytronSecurityDomainContextImpl implements SecurityDomainContext {
if (identity == null) {
return false;
}
- this.currentIdentity.set(identity);
+ // Do NOT stash identity into the per-thread currentIdentity here.
+ // isValid() exists to validate credentials and populate the caller's
+ // Subject (next line). Stashing into the ThreadLocal without a paired
+ // cleanup contract leaks this identity into the next Work item that
+ // runs on the same pool thread. Callers that need per-thread identity
+ // install should use pushSubjectContext() / cleanupSubjectContext()
+ // (paired) or runAs() (auto-cleared in finally).
SubjectUtil.fromSecurityIdentity(identity, subject);
return true;
}

View file

@ -0,0 +1,45 @@
# UNDF: UNDF-2026-000001307
# CWE-668 / MOAD-0003: A Leaked Context (minor) — TransactionRollbackSetupAction
# uses depth.set(null) instead of depth.remove() when the depth counter
# hits zero. Functional clear (depth.get() returns null on next read),
# but the ThreadLocal entry remains in the thread's threadLocals map,
# pinning the (now-null) holder reference until the thread dies.
#
# Defect: transactions/src/main/java/org/jboss/as/txn/deployment/
# TransactionRollbackSetupAction.java:102
#
# holder.depth += increment;
# if (holder.depth == 0) {
# depth.set(null); // <-- should be depth.remove()
# return holder.actuallyCleanUp;
# }
#
# Rationale: ThreadLocal.set(null) writes a null value into the entry but
# leaves the entry itself (with the ThreadLocal key reference) alive in
# the Thread's internal threadLocals map. Across a Java EE thread pool's
# lifetime, this accumulates one entry per ThreadLocal-holding class.
# ThreadLocal.remove() actually deletes the entry, allowing the
# threadLocals map to shrink and (importantly) decoupling the deployed
# WildFly classloader from the thread's reachability graph during
# undeploy/redeploy cycles.
#
# This is a minor MOAD-0003 instance — no value-leak (the next caller
# does `depth.get() == null` and re-initializes correctly). The defect
# is classloader retention during deployment churn, not security.
#
# No bench: lifecycle defect, not algorithmic.
--- a/transactions/src/main/java/org/jboss/as/txn/deployment/TransactionRollbackSetupAction.java
+++ b/transactions/src/main/java/org/jboss/as/txn/deployment/TransactionRollbackSetupAction.java
@@ -99,7 +99,9 @@ public class TransactionRollbackSetupAction implements SetupAction, Service {
holder.depth += increment;
if (holder.depth == 0) {
- depth.set(null);
+ // Use remove() rather than set(null) so the ThreadLocal entry
+ // is actually deleted from the thread's threadLocals map. set(null)
+ // leaves a null binding that pins the WildFly classloader during
+ // undeploy/redeploy cycles in app servers with persistent thread pools.
+ depth.remove();
return holder.actuallyCleanUp;
}
return false;