Nachum Barcohen created GUACAMOLE-2293:
------------------------------------------

             Summary: Active connection records leak in memory when a WebSocket 
client disconnects abruptly during socket teardown
                 Key: GUACAMOLE-2293
                 URL: https://issues.apache.org/jira/browse/GUACAMOLE-2293
             Project: Guacamole
          Issue Type: Bug
          Components: guacamole-auth-jdbc, guacamole-common
    Affects Versions: 1.6.0
            Reporter: Nachum Barcohen


h3. Symptoms
 * The Active Sessions admin view shows disconnected sessions that never clear.
 * "Kill session" (REST PATCH .../activeConnections with op:remove) returns 
HTTP 200, but the record reappears on refresh - the in-memory 
ActiveConnectionRecord is never removed.
 * guacd has no matching child process; the ghost is purely in guacamole-app 
memory.
 * Ghosts accumulate indefinitely (observed 83 leaked records, oldest ~35 days) 
and count against concurrency limits. With a balancing group at 
max_connections_per_user=1, a single own-ghost locks the user out of the whole 
group. Only restarting guacamole-app flushes them.
 * Log signature: java.lang.IllegalStateException: Message will not be sent 
because the WebSocket session has been closed, at 
GuacamoleWebSocketTunnelEndpoint.sendInstruction.

h3. Environment

Guacamole 1.6.0 (official Docker image), guacamole-auth-jdbc (MySQL) + LDAP + 
TOTP + ban, behind a reverse proxy / tunnel that does not always forward a 
WebSocket close frame to the origin (observed with a Cloudflare Zero Trust 
tunnel). The guacamole-app container is run with podman 
--security-opt=no-new-privileges. Predominantly RDP connections.
h3. Root cause

At disconnect there are two teardown paths, and cleanup can be skipped on 
either:
 # *Socket-close path (the actual leak).* @OnClose fires normally -> 
tunnel.close() -> ManagedInetGuacamoleSocket.close() (or the SSL variant). That 
method calls super.close() and only afterwards runs socketClosedTask - the 
ConnectionCleanupTask that removes the record from the in-memory 
ActiveConnectionMultimap and releases the concurrency seat. The cleanup call is 
{*}not in a finally{*}. When the underlying guacd socket is being torn down 
concurrently by the read thread, JDK 21's NioSocketImpl fabricates 
java.io.IOException: No such file or directory on the second close() (the 
close() syscall itself succeeds - verified via strace, close(fd)=0; the ENOENT 
is a JVM NIO double-close artifact, not a kernel or permission error). 
super.close() throws, the cleanup task is skipped, and the 
ActiveConnectionRecord leaks.
 # *Read-thread path (secondary symptom).* The transfer thread in 
GuacamoleWebSocketTunnelEndpoint catches only IOException. If the client WS 
closes mid-send, remote.sendText() throws an unchecked IllegalStateException (a 
RuntimeException); the thread dies before closeConnection() runs, so @OnClose 
never fires either.

h3. Conclusion

It is a timing race between the read side closing the guacd socket and the
@OnClose close. Running guacamole-app under podman no-new-privileges shifts JVM
thread scheduling enough to tip the race toward the throwing order (cap-drop=ALL
alone does not; only the NoNewPrivs bit matters). RDP's heavy frame bursts keep
the writer busy at disconnect and widen the window; light VNC streams stay 
clean.
This is scheduling, not a blocked syscall - strace during teardown shows zero
EPERM/EACCES and zero failed socket calls, and Seccomp=2 is identical between
clean and ghosting configs.
h3. Steps to reproduce
 # Run stock guacamole/guacamole:1.6.0 + guacd + MySQL, with guacamole-app 
started under --security-opt=no-new-privileges.
 # Configure at least 2 RDP connections to reachable Windows hosts.
 # Open 3 concurrent browser tabs to those RDP connections; let them connect 
(~15–30s).
 # Close all tabs near-simultaneously (close-storm).
 # Wait ~60s, then looks at the active connections list. Connections still 
there. You can also query the in-memory tracker as an admin: GET 
/api/session/data/mysql/activeConnections.

A single sequential connect/disconnect does not leak; the leak needs concurrent
live RDP sessions plus a near-simultaneous close-storm.
h3. Fix

Make cleanup order-independent so the record is always released:
 # ManagedInetGuacamoleSocket.close() and ManagedSSLGuacamoleSocket.close() - 
run socketClosedTask in a finally, so cleanup runs even when super.close() 
throws on an already-torn-down socket. (ConnectionCleanupTask is already 
guarded to run once via an AtomicBoolean, so this is safe.)
 # ActiveConnectionService.deleteObject() - drop the tunnel.isOpen() guard 
before tunnel.close(). For a connection whose peer already vanished isOpen() 
returns false, so "Kill session" was a no-op; always call close() so an admin 
can reap an existing  host. The guard doesn't make sense, if the tunnel.isOpen 
is false, there is no reason  to keep the record in memory.
 # GuacamoleWebSocketTunnelEndpoint (hardening) - catch RuntimeException/Error 
in the transfer thread and close the WS session in a finally, guaranteeing 
@OnClose  fires regardless of how the thread terminates.

Fixes 1-2 are the necessary fix; fix 3 is defensive hardening for the parallel
read-thread symptom. I have validated on my production setup and the issue is 
gone.

{*}Mailing-list context{*}: 
https://lists.apache.org/thread/fwppogp2rl6n6hcn2wq33t3fz0obn0wq



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to