Chisel has an ACL Bypass via Post-Handshake SSH Channel ExtraData Injection
Description
Authenticated chisel clients can bypass --authfile ACL restrictions by injecting arbitrary host:port targets in post-handshake SSH channels.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authenticated chisel clients can bypass `--authfile` ACL restrictions by injecting arbitrary `host:port` targets in post-handshake SSH channels.
Vulnerability
The vulnerability (CVE-2026-48113) exists in chisel, a fast TCP/UDP tunnel over HTTP, affecting servers configured with --authfile ACL restrictions. The ACL is correctly enforced during the initial configuration handshake in server/server_handler.go, where the server validates each declared remote against the user's allowed list [1]. However, in share/tunnel/tunnel_out_ssh.go, the handleSSHChannel and handleTCP functions accept and dial any host:port from the client-controlled ch.ExtraData() without any ACL check [1][2]. The tunnel.Config struct lacks a User field, allowed-address list, or ACL callback, so the user context from the handshake is never propagated to the tunnel layer [1]. Any authenticated user can bypass the ACL after the initial handshake. The vulnerability affects all chisel versions prior to the fix that delivers the user context to the tunnel layer and enforces ACLs on each new channel request.
Exploitation
An attacker must have valid credentials to authenticate with the chisel server (as defined in the --authfile). After authentication, the attacker presents a permitted remote during the handshake (e.g., one allowed by the ACL) to pass the initial validation [1]. Once the handshake succeeds, the attacker opens subsequent SSH channels where ch.ExtraData() is set to arbitrary host:port values—such as internal services like 10.0.0.1:3306 or 192.168.1.100:22—that are not restricted by the ACL [1][2]. The server accepts and dials these destinations unconditionally because no ACL check exists for post-handshake channels [1].
Impact
Successful exploitation allows an authenticated but limited client to tunnel traffic to any TCP destination reachable from the chisel server, bypassing the intended --authfile restrictions. This can lead to lateral movement inside the network, access to internal services, data exfiltration, or further compromise of systems behind the server [1][2]. The attack requires no special privileges beyond initial authentication; the server acts as an unrestricted proxy for the attacker's chosen targets.
Mitigation
The chisel project has addressed this issue by propagating the authenticated user context to the tunnel layer and adding ACL enforcement for every SSH channel request, ensuring that only permitted remotes can be dialed [1]. Users should update to the latest patched version of chisel as soon as it is released. If an immediate update is not possible, a workaround is to restrict network access from the chisel server to only necessary destinations using firewall rules, limiting the impact of the bypass [1]. There is no known exploitation in the wild at the time of publication, and the CVE is not listed on CISA's Known Exploited Vulnerabilities (KEV) catalog.
AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
144310b65667aEnforce auth ACL on tunnel channels
4 files changed · +339 −2
server/server_handler.go+7 −2 modified@@ -135,13 +135,18 @@ func (s *Server) handleWebsocket(w http.ResponseWriter, req *http.Request) { //successfuly validated config! r.Reply(true, nil) //tunnel per ssh connection - tunnel := tunnel.New(tunnel.Config{ + tunnelConfig := tunnel.Config{ Logger: l, Inbound: s.config.Reverse, Outbound: true, //server always accepts outbound Socks: s.config.Socks5, KeepAlive: s.config.KeepAlive, - }) + } + //enforce ACL on every channel, not just the initial config + if user != nil { + tunnelConfig.ACL = user.HasAccess + } + tunnel := tunnel.New(tunnelConfig) //bind eg, ctx := errgroup.WithContext(req.Context()) eg.Go(func() error {
share/tunnel/tunnel.go+3 −0 modified@@ -25,6 +25,9 @@ type Config struct { Outbound bool Socks bool KeepAlive time.Duration + //ACL optionally checks if a given address (host:port) is allowed. + //When set, outbound connections are denied if this returns false. + ACL func(addr string) bool } //Tunnel represents an SSH tunnel with proxy capabilities.
share/tunnel/tunnel_out_ssh.go+6 −0 modified@@ -46,6 +46,12 @@ func (t *Tunnel) handleSSHChannel(ch ssh.NewChannel) { ch.Reject(ssh.Prohibited, "SOCKS5 is not enabled") return } + //check ACL against the actual requested destination + if t.Config.ACL != nil && !socks && !t.Config.ACL(hostPort) { + t.Debugf("Denied connection to %s (ACL)", hostPort) + ch.Reject(ssh.Prohibited, "access denied") + return + } sshChan, reqs, err := ch.Accept() if err != nil { t.Debugf("Failed to accept stream: %s", err)
test/e2e/acl_channel_test.go+323 −0 added@@ -0,0 +1,323 @@ +package e2e_test + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "testing" + "time" + + chserver "github.com/jpillora/chisel/server" + "github.com/jpillora/chisel/share/cnet" + "github.com/jpillora/chisel/share/settings" + + "github.com/gorilla/websocket" + "golang.org/x/crypto/ssh" +) + +// dialChiselSSH connects to the chisel server via websocket and +// performs an SSH handshake as the given user. +func dialChiselSSH(t *testing.T, serverAddr, user, pass string) (ssh.Conn, <-chan ssh.NewChannel, <-chan *ssh.Request) { + t.Helper() + ws, _, err := (&websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + Subprotocols: []string{"chisel-v3"}, + }).Dial("ws://"+serverAddr, http.Header{}) + if err != nil { + t.Fatalf("websocket dial: %v", err) + } + conn := cnet.NewWebSocketConn(ws) + sc, chans, reqs, err := ssh.NewClientConn(conn, "", &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.Password(pass)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + }) + if err != nil { + t.Fatalf("ssh handshake: %v", err) + } + go ssh.DiscardRequests(reqs) + go func() { for c := range chans { c.Reject(ssh.Prohibited, "") } }() + return sc, chans, reqs +} + +// sendConfig sends the chisel config request with the given remotes. +func sendConfig(t *testing.T, sc ssh.Conn, remotes []*settings.Remote) { + t.Helper() + cfg, err := json.Marshal(settings.Config{Version: "0", Remotes: remotes}) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + ok, reply, err := sc.SendRequest("config", true, cfg) + if err != nil { + t.Fatalf("config request: %v", err) + } + if !ok { + t.Fatalf("config rejected: %s", reply) + } +} + +// TestAuthChannelDenied verifies that a channel to an unauthorized +// destination is rejected. +func TestAuthChannelDenied(t *testing.T) { + allowedPort := availablePort() + blockedPort := availablePort() + + blockedListener, err := net.Listen("tcp", "127.0.0.1:"+blockedPort) + if err != nil { + t.Fatal(err) + } + defer blockedListener.Close() + go func() { + for { + conn, err := blockedListener.Accept() + if err != nil { + return + } + conn.Write([]byte("FORBIDDEN")) + conn.Close() + } + }() + + // Start chisel server with ACL: user can only reach allowedPort + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "acl-test", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil { + t.Fatal(err) + } + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + serverAddr := "127.0.0.1:" + serverPort + + // Connect and send config with only the allowed remote + sc, _, _ := dialChiselSSH(t, serverAddr, "user", "pass") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + // Try to open a channel to the BLOCKED port — must be rejected + target := net.JoinHostPort("127.0.0.1", blockedPort) + ch, _, err := sc.OpenChannel("chisel", []byte(target)) + if err == nil { + ch.Close() + t.Fatalf("channel to blocked port %s was accepted", blockedPort) + } + t.Logf("channel to blocked port correctly rejected: %v", err) +} + +// TestAuthChannelAllowed verifies that a channel to an authorized +// destination is accepted. +func TestAuthChannelAllowed(t *testing.T) { + allowedPort := availablePort() + + // Start a TCP listener on the allowed port + allowedListener, err := net.Listen("tcp", "127.0.0.1:"+allowedPort) + if err != nil { + t.Fatal(err) + } + defer allowedListener.Close() + go func() { + for { + conn, err := allowedListener.Accept() + if err != nil { + return + } + conn.Write([]byte("ALLOWED")) + conn.Close() + } + }() + + // Start chisel server with ACL: user can only reach allowedPort + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "acl-test-allowed", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + if err := s.AddUser("user", "pass", fmt.Sprintf(`^127\.0\.0\.1:%s$`, allowedPort)); err != nil { + t.Fatal(err) + } + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + serverAddr := "127.0.0.1:" + serverPort + + // Connect and send config with the allowed remote + sc, _, _ := dialChiselSSH(t, serverAddr, "user", "pass") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", allowedPort, allowedPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + // Open channel to the allowed port — must succeed + target := net.JoinHostPort("127.0.0.1", allowedPort) + ch, reqs, err := sc.OpenChannel("chisel", []byte(target)) + if err != nil { + t.Fatalf("channel to allowed port %s was rejected: %v", allowedPort, err) + } + go ssh.DiscardRequests(reqs) + defer ch.Close() + + // Read data from the allowed target + buf := make([]byte, 64) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read from allowed channel: %v", err) + } + if string(buf[:n]) != "ALLOWED" { + t.Fatalf("expected 'ALLOWED', got %q", buf[:n]) + } + t.Logf("channel to allowed port works correctly, received: %s", buf[:n]) +} + +// TestNoAuthChannel verifies that when no auth is configured, +// all destinations are reachable. +func TestNoAuthChannel(t *testing.T) { + targetPort := availablePort() + + // Start a TCP listener + listener, err := net.Listen("tcp", "127.0.0.1:"+targetPort) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Write([]byte("OPEN")) + conn.Close() + } + }() + + // Start chisel server with NO auth + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "no-acl-test", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + serverAddr := "127.0.0.1:" + serverPort + + // Connect with any credentials (server accepts all when no auth configured) + sc, _, _ := dialChiselSSH(t, serverAddr, "anyone", "anything") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", targetPort, targetPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + // Open channel — should be accepted since no ACL + target := net.JoinHostPort("127.0.0.1", targetPort) + ch, creqs, err := sc.OpenChannel("chisel", []byte(target)) + if err != nil { + t.Fatalf("channel rejected when no ACL is configured: %v", err) + } + go ssh.DiscardRequests(creqs) + defer ch.Close() + + buf := make([]byte, 64) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read: %v", err) + } + if string(buf[:n]) != "OPEN" { + t.Fatalf("expected 'OPEN', got %q", buf[:n]) + } + t.Logf("no-ACL mode works correctly") +} + +// TestAuthWildcardChannel verifies that a user with wildcard access +// can reach any destination. +func TestAuthWildcardChannel(t *testing.T) { + targetPort := availablePort() + + listener, err := net.Listen("tcp", "127.0.0.1:"+targetPort) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Write([]byte("WILDCARD")) + conn.Close() + } + }() + + s, err := chserver.NewServer(&chserver.Config{ + KeySeed: "acl-wildcard-test", + Auth: "admin:secret", + }) + if err != nil { + t.Fatal(err) + } + s.Debug = debug + serverPort := availablePort() + if err := s.Start("127.0.0.1", serverPort); err != nil { + t.Fatal(err) + } + defer s.Close() + + sc, _, _ := dialChiselSSH(t, "127.0.0.1:"+serverPort, "admin", "secret") + defer sc.Close() + + r, err := settings.DecodeRemote(fmt.Sprintf("0.0.0.0:%s:127.0.0.1:%s", targetPort, targetPort)) + if err != nil { + t.Fatal(err) + } + sendConfig(t, sc, []*settings.Remote{r}) + + target := net.JoinHostPort("127.0.0.1", targetPort) + ch, reqs, err := sc.OpenChannel("chisel", []byte(target)) + if err != nil { + t.Fatalf("wildcard user channel rejected: %v", err) + } + go ssh.DiscardRequests(reqs) + defer ch.Close() + + buf := make([]byte, 64) + n, err := ch.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read: %v", err) + } + if string(buf[:n]) != "WILDCARD" { + t.Fatalf("expected 'WILDCARD', got %q", buf[:n]) + } + t.Logf("wildcard user correctly allowed") +}
Vulnerability mechanics
Root cause
"Missing ACL enforcement on per-channel SSH connections allows bypass of --authfile restrictions."
Attack vector
An attacker authenticates as a valid chisel user whose `--authfile` allows only a specific remote address. After the initial config handshake passes the ACL check in `server/server_handler.go`, the attacker opens an SSH channel of type `"chisel"` and sets the `ExtraData` field to an arbitrary `host:port` destination that is not in the ACL [CWE-862] [ref_id=1]. The server's `handleSSHChannel` function reads this attacker-controlled `ExtraData` and dials the target without any ACL validation, letting the attacker tunnel traffic to any host or port reachable from the server [ref_id=2].
Affected code
The vulnerability exists in `share/tunnel/tunnel_out_ssh.go` and `share/tunnel/tunnel.go`. The `handleSSHChannel` function accepts and processes channel requests without checking the ACL, and the `Config` struct lacks any user or ACL callback field to propagate restrictions from `server/server_handler.go` [ref_id=1] [ref_id=2]. The fix adds an `ACL` field to the tunnel `Config` and checks it before accepting each channel in `tunnel_out_ssh.go` [patch_id=5722257].
What the fix does
The patch adds an `ACL` callback field of type `func(addr string) bool` to the `tunnel.Config` struct in `share/tunnel/tunnel.go` [patch_id=5722257]. In `server/server_handler.go`, when a user is authenticated, the server passes `user.HasAccess` as the ACL callback into the tunnel configuration. In `share/tunnel/tunnel_out_ssh.go`, `handleSSHChannel` now checks `t.Config.ACL` against the actual `hostPort` extracted from the channel's `ExtraData` before accepting the channel; if the check fails, the channel is rejected with `ssh.Prohibited`. This ensures every outbound connection is validated against the user's allowed-address ACL, not just the remotes declared in the initial config handshake.
Preconditions
- authAttacker must have valid chisel credentials (username/password) that are configured in the server's --authfile
- configServer must be running with --authfile enforcing address-based ACL restrictions
- networkThe attacker must be able to establish a WebSocket connection and perform an SSH handshake with the chisel server
- inputAttacker sends an SSH channel open request with arbitrary host:port in the ExtraData field
Reproduction
Run the `poc/poc.sh` script against a vulnerable chisel server. The script (1) starts a chisel server with an `--authfile` that restricts user:pass to only `127.0.0.1:<allowed_port>`, (2) starts a listener on a blocked port, and (3) runs the Go probe which authenticates, sends a config containing only the allowed remote, then opens an SSH channel with `ExtraData` set to the blocked port. If the server is vulnerable, the probe prints `"CONFIRMED — ACL bypass: server dialed unauthorized destination"` [ref_id=1].
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.