diff --git a/.gitignore b/.gitignore index def3c33..dcc8aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ build/ *.pb.go # Go workspace file +cryptolib go.work .idea config.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4928ddb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY ./ ./ +RUN set -eux; \ + apk add --no-cache bash make git protoc protobuf-dev dumb-init mailcap ca-certificates tzdata; \ + update-ca-certificates; \ + make install_protobuf build + +FROM scratch +WORKDIR / +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=builder /etc/mime.types /etc/mime.types +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /usr/bin/dumb-init /bin/dumb-init +COPY --from=builder /app/build/sshpoke /sshpoke +EXPOSE 25680 +EXPOSE 25681 +ENTRYPOINT ["/bin/dumb-init", "--"] +CMD ["/sshpoke"] diff --git a/Makefile b/Makefile index 547806b..ba0e127 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ GO_VERSION=$(shell go version | sed -e 's/go version //') VERSION=$(shell git describe --tags 2>/dev/null || git log --format="v0.0.0-%h" -n 1 || echo "v0.0.0-unknown") LDFLAGS="-X 'github.com/Neur0toxine/sshpoke/cmd.Version=${VERSION}'" -.PHONY: run clean_backend +.PHONY: run clean_backend clone_sshlib build: generate deps fmt @echo " ► Building with ${GO_VERSION}" @@ -42,17 +42,23 @@ install_protobuf: @go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28 @go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2 -update_sshlib: +clone_cryptolib: @rm -rf cryptolib && \ git clone https://go.googlesource.com/crypto cryptolib && \ - rm -rf pkg/proto/ssh && \ + mv cryptolib/internal/poly1305 cryptolib/ssh/internal/ && \ + find cryptolib/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/ssh?github.com/Neur0toxine/sshpoke/pkg/proto/ssh?g' {} \; && \ + find cryptolib/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/internal/poly1305?github.com/Neur0toxine/sshpoke/pkg/proto/ssh/internal/poly1305?g' {} \; && \ + find cryptolib/ssh -type f -name '*_test.go' -delete && \ + rm -rf cryptolib/ssh/test && \ + rm -rf cryptolib/ssh/testdata + +update_sshlib_patch: + @diff -Naru cryptolib/ssh pkg/proto/ssh > patch/ssh_proto_patches.patch + +update_sshlib: clone_cryptolib + @mv pkg/proto/ssh pkg/proto/ssh.bak && \ mv cryptolib/ssh pkg/proto/ && \ - mv cryptolib/internal/poly1305 pkg/proto/ssh/internal/ && \ - find pkg/proto/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/ssh?github.com/Neur0toxine/sshpoke/pkg/proto/ssh?g' {} \; && \ - find pkg/proto/ssh -type f -name '*.go' -exec sed -i 's?golang.org/x/crypto/internal/poly1305?github.com/Neur0toxine/sshpoke/pkg/proto/ssh/internal/poly1305?g' {} \; && \ - find pkg/proto/ssh -type f -name '*_test.go' -delete && \ - patch -p0 < patch/ssh_fakehost.patch && \ - rm -rf pkg/proto/ssh/test && \ - rm -rf pkg/proto/ssh/testdata && \ + patch -p0 < patch/ssh_proto_patches.patch && \ + rm -rf pkg/proto/ssh.bak && \ rm -rf cryptolib diff --git a/config.example.yml b/config.example.yml deleted file mode 100644 index a506a78..0000000 --- a/config.example.yml +++ /dev/null @@ -1,145 +0,0 @@ -# Enable or disable debug logging. -debug: true -# API settings. -api: - # Local port for Web API. Will be bound to localhost. - web_port: 25680 - # Local port for plugin API. Will listen on all interfaces because it has auth. - plugin_port: 25681 -# Docker client preferences. -docker: - # Extract client params from the environment. - from_env: true - # Cert path for the Docker client. - cert_path: ~ - # Set it to false to disable TLS cert verification. - tls_verify: true - # Docker host. Can be useful for running containers alongside remote plugin (although it sounds weird to do so). - host: ~ - # Docker version. - version: ~ -# Default server to use if `sshpoke.server` is not specified in the target container labels. -default_server: mine -# Servers configuration. -servers: - # Server name. - - name: mine - # Server driver. Each driver has its own set of params. Supported drivers: ssh, plugin, null. - driver: ssh - params: - # SSH server address - address: "your1.server:2222" - # Remote port to be used for forwarding. - forward_port: 80 - # This disables remote host resolution and forcibly uses server IP for remote host. - # It's the same as this syntax for sish: `ssh -R addr:80:localhost:80 your.sish.server` - # Set this to true if you're using sish, otherwise you'll get weird domains with IP's in them. - fake_remote_host: true - # Disables PTY request for this server. - nopty: true - # Requests interactive shell for SSH sessions. Should be `true` for the `commands`. - # You can also pass a string with shell binary, for example, "/bin/sh". - # Note: commands will be executed using provided shell binary. - shell: false - # Spoof client version with provided (value below is taken directly from OpenSSH). This value must be compliant with RFC-4253. - client_version: "SSH-2.0-OpenSSH_9.5" - # Authentication data. - auth: - # Authentication type. Supported types: key, password, passwordless - type: key - # Remote user - user: user - # Directory with SSH keys. ssh-config from this directory will be used if `keyfile` is not provided. - # Supported ssh-config directives: HostName, Port, User, IdentityFile - # known_hosts from this directory will be used if `host_keys` is not provided. - directory: "~/.ssh" - # Expose mode (multiple domains or single domain). Allowed values: single, multi. - mode: multi - # Keep-alive settings. Remove to disable keep-alive completely. - keepalive: - # Interval for keep-alive requests in seconds. - interval: 1 - # How many attempts should fail to forcibly restart the connection. - max_attempts: 2 - # Regular expression that will be used to extract domain from stdout & stderr. Useful for services like sish or - # localhost.run. `commands` output will also be parsed by this regex. - # With `!name` syntax you can use some built-in expressions: - # - !webUrl - any HTTP or HTTPS URL. - # - !httpUrl - any HTTP URL. - # - !httpsUrl - any HTTPS URL. - domain_extract_regex: "!httpsUrl" - # Host keys to prevent MITM. You can obtain those via `ssh-keyscan
` (specify `-p` for non-standard port). - # Always use '|' YAML syntax here (not '>') or sshpoke won't be able to parse keys. - host_keys: | - # ssh.neur0tx.site:2222 SSH-2.0-sish - # ssh.neur0tx.site:2222 SSH-2.0-sish - # ssh.neur0tx.site:2222 SSH-2.0-sish - [ssh.neur0tx.site]:2222 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEvxbqK0u8UjqEtrO/83GPS7MeoFp6C3+7KjOHd8+1GF - # ssh.neur0tx.site:2222 SSH-2.0-sish - # ssh.neur0tx.site:2222 SSH-2.0-sish - - name: ssh-demo-single-domain - driver: ssh - params: - auth: - type: key - user: user - directory: "~/.ssh" - keyfile: id_ed25519 - address: "your2.server" - forward_port: 80 - fake_remote_host: true - nopty: false - shell: "/usr/bin/bash" - mode: single - keepalive: - interval: 1 - max_attempts: 2 - domain_extract_regex: "!webUrl" - - name: ssh-demo-commands - driver: ssh - params: - address: "your3.server" - forward_port: 8080 - auth: - type: key - user: user - directory: "~/.ssh" - mode: multi - keepalive: - interval: 1 - max_attempts: 2 - domain_extract_regex: "!webUrl" - # Commands that will be executed on the host. - commands: - # These commands will be executed after connect. - on_connect: - - echo https://`date +%s`.proxy.test - # These commands will be executed before disconnect. - on_disconnect: - - echo disconnect from `cat /etc/hostname` - - name: ssh-demo-with-password - driver: ssh - params: - address: "ssh.neur0tx.site" - forward_port: 8081 - auth: - type: password - user: user - # Remote user password. - password: password - mode: multi - keepalive: - interval: 1 - max_attempts: 2 - domain_extract_regex: "!httpUrl" - commands: - on_connect: - - echo http://`date +%s`.proxy.test - - name: plugin-demo - driver: plugin - params: - # This token will be used by plugin while connecting to gRPC API. - token: key - - name: noop - # Null driver doesn't do anything. This driver will automatically be used for servers with invalid 'driver' value. - driver: null diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml new file mode 100644 index 0000000..48b3847 --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,10 @@ +version: "3.8" +services: + sshpoke: + image: "neur0toxine/sshpoke:latest" + container_name: "sshpoke" + network_mode: host + restart: always + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./config.yml:/config.yml \ No newline at end of file diff --git a/internal/api/server.go b/internal/api/server.go index e348bac..2aa009d 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -61,7 +61,7 @@ func (p *pluginAPI) receiverForContext(ctx context.Context) plugin.Plugin { } func StartPluginAPI() { - port := config.Default.API.PluginPort + port := config.Default.API.Plugin.Port if port == 0 { port = plugin2.DefaultPort } diff --git a/internal/config/model.go b/internal/config/model.go index 9d7a377..de4f144 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -19,8 +19,17 @@ type Config struct { } type API struct { - WebPort int `mapstructure:"web_port" validate:"gte=0,lte=65535"` - PluginPort int `mapstructure:"plugin_port" validate:"gte=0,lte=65535"` + Web WebAPI `mapstructure:"web"` + Plugin PluginAPI `mapstructure:"plugin"` +} + +type WebAPI struct { + Port int `mapstructure:"port" validate:"gte=0,lte=65535"` + Token string `mapstructure:"token"` +} + +type PluginAPI struct { + Port int `mapstructure:"port" validate:"gte=0,lte=65535"` } type DockerConfig struct { diff --git a/internal/server/driver/ssh/driver.go b/internal/server/driver/ssh/driver.go index 1f057e0..9e97df2 100644 --- a/internal/server/driver/ssh/driver.go +++ b/internal/server/driver/ssh/driver.go @@ -20,6 +20,7 @@ import ( "github.com/Neur0toxine/sshpoke/pkg/dto" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh" "github.com/Neur0toxine/sshpoke/pkg/proto/ssh/knownhosts" + "go.uber.org/zap" ) const KnownHostsFile = "known_hosts" @@ -85,17 +86,42 @@ func (d *SSH) forward(val sshtun.Forward, domainMatcher func(string)) conn { d.Log()) ctx, cancel := context.WithCancel(d.Context()) tunDbgLog := d.Log().With("ssh-output", val.Remote.String()) - go tun.Connect(ctx, - sshtun.BannerDebugLogCallback(tunDbgLog), - sshtun.OutputReaderCallback(func(msg string) { - d.Log().Debug(msg) + var outputReaderCb sshtun.SessionCallback + if d.params.ReadSessionsOutput == nil || (*d.params.ReadSessionsOutput) { + outputReaderCb = sshtun.OutputReaderCallback(func(msg string) { + msg = strings.TrimSpace(msg) + if msg == "" { + return + } + tunDbgLog.Debug("session: ", msg) if domainMatcher != nil { domainMatcher(msg) } - })) + }) + } + go tun.Connect(ctx, + d.buildHandshakeLineCallback(domainMatcher, tunDbgLog), + sshtun.BannerDebugLogCallback(tunDbgLog), + outputReaderCb) return conn{ctx: ctx, cancel: cancel, tun: tun} } +func (d *SSH) buildHandshakeLineCallback(domainMatcher func(string), tunDbgLog *zap.SugaredLogger) func(string) { + if d.params.ReadRawPackets { + return func(msg string) { + msg = strings.TrimSpace(msg) + if msg == "" { + return + } + tunDbgLog.Debugf("ssh: %s", msg) + if domainMatcher != nil { + domainMatcher(msg) + } + } + } + return nil +} + func (d *SSH) buildHostKeyCallback() ssh.HostKeyCallback { keysCallback := func() ssh.HostKeyCallback { if d.hostKeys == nil || len(d.hostKeys) == 0 { @@ -280,6 +306,10 @@ func (d *SSH) remoteEndpoint(remoteHost string) sshtun.Endpoint { if port == 0 { port = 80 } + if remoteHost == "" && !d.params.FakeRemoteHost { + // Listen on all interfaces if no host was provided. + remoteHost = "0.0.0.0" + } return sshtun.Endpoint{ Host: remoteHost, Port: port, diff --git a/internal/server/driver/ssh/params.go b/internal/server/driver/ssh/params.go index e61ecca..0bd0599 100644 --- a/internal/server/driver/ssh/params.go +++ b/internal/server/driver/ssh/params.go @@ -15,6 +15,8 @@ type Params struct { Auth types.Auth `mapstructure:"auth"` KeepAlive types.KeepAlive `mapstructure:"keepalive"` DomainExtractRegex string `mapstructure:"domain_extract_regex" validate:"validregexp"` + ReadRawPackets bool `mapstructure:"read_raw_packets"` + ReadSessionsOutput *bool `mapstructure:"read_sessions_output"` Mode types.DomainMode `mapstructure:"mode" validate:"required,oneof=single multi"` FakeRemoteHost bool `mapstructure:"fake_remote_host"` NoPTY bool `mapstructure:"nopty"` diff --git a/internal/server/driver/ssh/sshtun/connect.go b/internal/server/driver/ssh/sshtun/connect.go index c12b451..9cd84b0 100644 --- a/internal/server/driver/ssh/sshtun/connect.go +++ b/internal/server/driver/ssh/sshtun/connect.go @@ -39,11 +39,11 @@ type TunnelConfig struct { type BoolOrStr string func (b BoolOrStr) IsBool() bool { - return b == "true" || b == "false" + return b == "true" || b == "false" || b == "1" || b == "0" || b == "" } func (b BoolOrStr) Falsy() bool { - return b == "" || b == "false" + return b == "" || b == "0" || b == "false" } func (b BoolOrStr) String() string { @@ -60,7 +60,8 @@ func New(address, user string, auth []ssh.AuthMethod, sc TunnelConfig, log *zap. } } -func (t *Tunnel) Connect(ctx context.Context, bannerCb ssh.BannerCallback, sessionCb SessionCallback) { +func (t *Tunnel) Connect(ctx context.Context, + hsLineCb func(msg string), bannerCb ssh.BannerCallback, sessionCb SessionCallback) { if t.connected.Load() { return } @@ -69,7 +70,7 @@ func (t *Tunnel) Connect(ctx context.Context, bannerCb ssh.BannerCallback, sessi backoffTime := backoff.ExponentialWithCappedMax(100*time.Millisecond, 5*time.Second) for { t.connected.Store(true) - err := t.connect(ctx, bannerCb, sessionCb) + err := t.connect(ctx, hsLineCb, bannerCb, sessionCb) if err != nil { t.log.Error("connect error: ", err) } @@ -86,9 +87,13 @@ func (t *Tunnel) Connect(ctx context.Context, bannerCb ssh.BannerCallback, sessi // connect once to the SSH server. if the connection breaks, we return error and the caller // will try to re-connect -func (t *Tunnel) connect(ctx context.Context, bannerCb ssh.BannerCallback, sessionCb SessionCallback) error { +func (t *Tunnel) connect(ctx context.Context, + hsLineCb func(string), bannerCb ssh.BannerCallback, sessionCb SessionCallback) error { t.log.Debug("connecting") sshConfig := &ssh.ClientConfig{ + Config: ssh.Config{ + HandshakePacketReader: newHandshakePerLineReader(hsLineCb), + }, User: t.user, Auth: t.authMethods, HostKeyCallback: t.tunConfig.HostKeyCallback, diff --git a/internal/server/driver/ssh/sshtun/keepalive.go b/internal/server/driver/ssh/sshtun/keepalive.go index 54a5e4e..76e43a3 100644 --- a/internal/server/driver/ssh/sshtun/keepalive.go +++ b/internal/server/driver/ssh/sshtun/keepalive.go @@ -15,7 +15,7 @@ import ( // assume that the underlying net.Conn abruptly died. func (t *Tunnel) keepAlive(ctx context.Context, client *ssh.Client, wg *sync.WaitGroup) { defer wg.Done() - if t.tunConfig.KeepAliveInterval == 0 || t.tunConfig.KeepAliveMax == 0 { + if t.tunConfig.KeepAliveInterval <= 0 || t.tunConfig.KeepAliveMax <= 0 { return } diff --git a/internal/server/driver/ssh/sshtun/packet.go b/internal/server/driver/ssh/sshtun/packet.go new file mode 100644 index 0000000..f5338b0 --- /dev/null +++ b/internal/server/driver/ssh/sshtun/packet.go @@ -0,0 +1,31 @@ +package sshtun + +type handshakePerLineReader struct { + buf []byte + off int + cb func(line string) +} + +func newHandshakePerLineReader(cb func(string)) func([]byte) { + if cb == nil { + return nil + } + lr := &handshakePerLineReader{cb: cb, buf: make([]byte, 1024)} + return lr.packet +} + +func (h *handshakePerLineReader) packet(p []byte) { + for _, char := range p { + if (char == 10 || char == 13) && len(h.buf) > 0 { + h.cb(string(h.buf[:h.off])) + h.off = 0 + } + if cap(h.buf) <= (h.off + 1) { + newBuf := make([]byte, cap(h.buf)+256) + copy(newBuf, h.buf) + h.buf = newBuf + } + h.buf[h.off] = char + h.off++ + } +} diff --git a/internal/server/driver/ssh/sshtun/printer.go b/internal/server/driver/ssh/sshtun/printer.go index b241183..a131518 100644 --- a/internal/server/driver/ssh/sshtun/printer.go +++ b/internal/server/driver/ssh/sshtun/printer.go @@ -37,7 +37,7 @@ func OutputReaderCallback(callback func(string)) SessionCallback { func BannerDebugLogCallback(log *zap.SugaredLogger) ssh.BannerCallback { return func(msg string) error { - log.Debug(msg) + log.Debug("banner: ", msg) return nil } } diff --git a/internal/server/driver/ssh/types/keep_alive.go b/internal/server/driver/ssh/types/keep_alive.go index 519a3bc..ace3d60 100644 --- a/internal/server/driver/ssh/types/keep_alive.go +++ b/internal/server/driver/ssh/types/keep_alive.go @@ -2,5 +2,5 @@ package types type KeepAlive struct { Interval int `mapstructure:"interval" validate:"gte=0"` - MaxAttempts int `mapstructure:"max_attempts" validate:"gte=1"` + MaxAttempts int `mapstructure:"max_attempts" validate:"gte=0"` } diff --git a/patch/ssh_fakehost.patch b/patch/ssh_proto_patches.patch similarity index 55% rename from patch/ssh_fakehost.patch rename to patch/ssh_proto_patches.patch index 8d9b7e3..eb5b671 100644 --- a/patch/ssh_fakehost.patch +++ b/patch/ssh_proto_patches.patch @@ -1,5 +1,68 @@ ---- cryptolib/ssh/tcpip.go 2023-11-18 22:20:50.609146922 +0300 -+++ pkg/proto/ssh/tcpip.go 2023-11-18 22:19:08.891684669 +0300 +diff -Naru cryptolib/ssh/cipher.go pkg/proto/ssh/cipher.go +--- cryptolib/ssh/cipher.go 2023-11-21 18:52:03.117248053 +0300 ++++ pkg/proto/ssh/cipher.go 2023-11-21 17:19:04.688042738 +0300 +@@ -16,8 +16,8 @@ + "hash" + "io" + +- "golang.org/x/crypto/chacha20" + "github.com/Neur0toxine/sshpoke/pkg/proto/ssh/internal/poly1305" ++ "golang.org/x/crypto/chacha20" + ) + + const ( +diff -Naru cryptolib/ssh/common.go pkg/proto/ssh/common.go +--- cryptolib/ssh/common.go 2023-11-21 18:52:03.217249582 +0300 ++++ pkg/proto/ssh/common.go 2023-11-21 17:28:23.221203344 +0300 +@@ -7,14 +7,13 @@ + import ( + "crypto" + "crypto/rand" ++ _ "crypto/sha1" ++ _ "crypto/sha256" ++ _ "crypto/sha512" + "fmt" + "io" + "math" + "sync" +- +- _ "crypto/sha1" +- _ "crypto/sha256" +- _ "crypto/sha512" + ) + + // These are string constants in the SSH protocol. +@@ -279,6 +278,9 @@ + // The allowed MAC algorithms. If unspecified then a sensible default is + // used. Unsupported values are silently ignored. + MACs []string ++ ++ // Called on every incoming handshake packet for client. Only receives data and extended data packets. ++ HandshakePacketReader func(p []byte) + } + + // SetDefaults sets sensible values for unset fields in config. This is +diff -Naru cryptolib/ssh/handshake.go pkg/proto/ssh/handshake.go +--- cryptolib/ssh/handshake.go 2023-11-21 18:52:03.209249460 +0300 ++++ pkg/proto/ssh/handshake.go 2023-11-21 18:51:58.681180283 +0300 +@@ -401,6 +401,14 @@ + t.printPacket(p, false) + } + ++ if t.config.HandshakePacketReader != nil && p[0] == msgChannelData { ++ data, err := decode(p) ++ packetData, ok := data.(*channelDataMsg) ++ if err == nil && ok && packetData != nil { ++ t.config.HandshakePacketReader(packetData.Rest) ++ } ++ } ++ + if first && p[0] != msgKexInit { + return nil, fmt.Errorf("ssh: first packet should be msgKexInit") + } +diff -Naru cryptolib/ssh/tcpip.go pkg/proto/ssh/tcpip.go +--- cryptolib/ssh/tcpip.go 2023-11-21 18:52:03.129248237 +0300 ++++ pkg/proto/ssh/tcpip.go 2023-11-21 17:19:04.688042738 +0300 @@ -101,14 +101,18 @@ // ListenTCP requests the remote peer open a listening socket // on laddr. Incoming connections will be available by calling diff --git a/pkg/proto/ssh/common.go b/pkg/proto/ssh/common.go index dd2ab0d..d14bbe4 100644 --- a/pkg/proto/ssh/common.go +++ b/pkg/proto/ssh/common.go @@ -7,14 +7,13 @@ package ssh import ( "crypto" "crypto/rand" + _ "crypto/sha1" + _ "crypto/sha256" + _ "crypto/sha512" "fmt" "io" "math" "sync" - - _ "crypto/sha1" - _ "crypto/sha256" - _ "crypto/sha512" ) // These are string constants in the SSH protocol. @@ -279,6 +278,9 @@ type Config struct { // The allowed MAC algorithms. If unspecified then a sensible default is // used. Unsupported values are silently ignored. MACs []string + + // Called on every incoming handshake packet for client. Only receives data and extended data packets. + HandshakePacketReader func(p []byte) } // SetDefaults sets sensible values for unset fields in config. This is diff --git a/pkg/proto/ssh/handshake.go b/pkg/proto/ssh/handshake.go index 49bbba7..7ac150a 100644 --- a/pkg/proto/ssh/handshake.go +++ b/pkg/proto/ssh/handshake.go @@ -401,6 +401,14 @@ func (t *handshakeTransport) readOnePacket(first bool) ([]byte, error) { t.printPacket(p, false) } + if t.config.HandshakePacketReader != nil && p[0] == msgChannelData { + data, err := decode(p) + packetData, ok := data.(*channelDataMsg) + if err == nil && ok && packetData != nil { + t.config.HandshakePacketReader(packetData.Rest) + } + } + if first && p[0] != msgKexInit { return nil, fmt.Errorf("ssh: first packet should be msgKexInit") }