[ 
https://issues.apache.org/jira/browse/GUACAMOLE-2293?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Nachum Barcohen updated GUACAMOLE-2293:
---------------------------------------
    Description: 
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.

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

The in-memory ActiveConnectionRecord is removed, and its concurrency seat 
released, by the ConnectionCleanupTask (socketClosedTask) that runs inside 
ManagedInetGuacamoleSocket.close() / ManagedSSLGuacamoleSocket.close(). That 
task is invoked *after* super.close() and is *not* in a finally block.

At disconnect the underlying guacd socket is closed nearly simultaneously by 
the read thread and by the @OnClose handler. When close() races, JDK 21's 
NioSocketImpl fabricates java.io.IOException: No such file or directory on the 
second close of the same fd (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() then throws, the cleanup task is 
skipped, and the ActiveConnectionRecord leaks.

Separately, ActiveConnectionService.deleteObject() only calls tunnel.close() 
when tunnel.isOpen() is true. AbstractGuacamoleTunnel.isOpen() delegates to the 
underlying socket, so once the peer has vanished isOpen() returns false and 
"Kill session" becomes a no-op - admin cannot reap an already-leaked ghost.
h3. Conclusion

Whether super.close() throws is a timing race. Running guacamole-app under 
podman no-new-privileges probably 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 both threads busy at 
disconnect and widen the window, while light VNC streams stay clean. As it 
seems, it's a scheduling issue, 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 (deterministic harness)
 # 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 query the in-memory tracker as an admin:
GET /api/session/data/mysql/activeConnections — one or more records persist 
with no client connected.

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(). always call close() so an admin can reap an existing 
ghost. It doesn't make sense either way. If tunnel is closed, there is no 
reason to keep the record in memory.

After having a stable reproduction I verified the fix, and then deployed to my 
production.

Note: the related uncaught-IllegalStateException path in the WebSocket read 
thread (sendInstruction -> remote.sendText) is already handled on main by 
GUACAMOLE-2181, which converts that IllegalStateException into an IOException 
the read thread already catches. This issue is the remaining, independent leak 
in the socket-close cleanup path and is not addressed by GUACAMOLE-2181.

  was:
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


> Active connection record leaks in memory when the guacd socket close throws 
> during disconnect
> ---------------------------------------------------------------------------------------------
>
>                 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
>            Priority: Major
>
> 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.
> 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
> The in-memory ActiveConnectionRecord is removed, and its concurrency seat 
> released, by the ConnectionCleanupTask (socketClosedTask) that runs inside 
> ManagedInetGuacamoleSocket.close() / ManagedSSLGuacamoleSocket.close(). That 
> task is invoked *after* super.close() and is *not* in a finally block.
> At disconnect the underlying guacd socket is closed nearly simultaneously by 
> the read thread and by the @OnClose handler. When close() races, JDK 21's 
> NioSocketImpl fabricates java.io.IOException: No such file or directory on 
> the second close of the same fd (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() then throws, the 
> cleanup task is skipped, and the ActiveConnectionRecord leaks.
> Separately, ActiveConnectionService.deleteObject() only calls tunnel.close() 
> when tunnel.isOpen() is true. AbstractGuacamoleTunnel.isOpen() delegates to 
> the underlying socket, so once the peer has vanished isOpen() returns false 
> and "Kill session" becomes a no-op - admin cannot reap an already-leaked 
> ghost.
> h3. Conclusion
> Whether super.close() throws is a timing race. Running guacamole-app under 
> podman no-new-privileges probably 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 both threads busy at 
> disconnect and widen the window, while light VNC streams stay clean. As it 
> seems, it's a scheduling issue, 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 (deterministic harness)
>  # 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 query the in-memory tracker as an admin:
> GET /api/session/data/mysql/activeConnections — one or more records persist 
> with no client connected.
> 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(). always call close() so an admin can reap an existing 
> ghost. It doesn't make sense either way. If tunnel is closed, there is no 
> reason to keep the record in memory.
> After having a stable reproduction I verified the fix, and then deployed to 
> my production.
> Note: the related uncaught-IllegalStateException path in the WebSocket read 
> thread (sendInstruction -> remote.sendText) is already handled on main by 
> GUACAMOLE-2181, which converts that IllegalStateException into an IOException 
> the read thread already catches. This issue is the remaining, independent 
> leak in the socket-close cleanup path and is not addressed by GUACAMOLE-2181.



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

Reply via email to