ssh: raw handshake support, settings: API settings update & loading fixes

This commit is contained in:
Pavel 2023-11-21 18:55:04 +03:00
parent fa9b7b1838
commit 0f27400a48
17 changed files with 218 additions and 177 deletions

1
.gitignore vendored
View File

@ -21,6 +21,7 @@ build/
*.pb.go
# Go workspace file
cryptolib
go.work
.idea
config.yml

19
Dockerfile Normal file
View File

@ -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"]

View File

@ -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

View File

@ -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 <address>` (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

View File

@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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,

View File

@ -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"`

View File

@ -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,

View File

@ -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
}

View File

@ -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++
}
}

View File

@ -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
}
}

View File

@ -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"`
}

View File

@ -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

View File

@ -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

View File

@ -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")
}