Skip to content

Latest commit

 

History

History
executable file
·
454 lines (366 loc) · 15.7 KB

04.4.md

File metadata and controls

executable file
·
454 lines (366 loc) · 15.7 KB

04.4 - SSH clients

Next in line is creating SSH clients. The /x/crypto/ssh provides SSH support. It's not one of the standard libraries so you need to go get golang.org/x/crypto/ssh before use.

We can authenticate using either user/pass or certificate.

Basic interactive session with user/pass

First program is a typical interactive session based on the example in the docs. We login with a user/pass combo.

// 04.4-01-sshclient-login-password.go
// Interactive SSH login with user/pass.

package main

import (
    "flag"
    "fmt"
    "io"
    "net"
    "os"

    // Importing crypto/ssh
    "golang.org/x/crypto/ssh"
)

var (
    username, password, serverIP, serverPort string
)

// Read flags
func init() {
    flag.StringVar(&serverPort, "port", "22", "SSH server port")
    flag.StringVar(&serverIP, "ip", "127.0.0.1", "SSH server IP")
    flag.StringVar(&username, "user", "", "username")
    flag.StringVar(&password, "pass", "", "password")
}

func main() {
    // Parse flags
    flag.Parse()

    // Check if username has been submitted - password can be empty
    if username == "" {
        fmt.Println("Must supply username")
        os.Exit(2)
    }

    // Create SSH config
    config := &ssh.ClientConfig{
        // Username
        User: username,
        // Each config must have one AuthMethod. In this case we use password
        Auth: []ssh.AuthMethod{
            ssh.Password(password),
        },
        // This callback function validates the server.
        // Danger! We are ignoring host info
        HostKeyCallback: ssh.InsecureIgnoreHostKey(),
    }

    // Server address
    t := net.JoinHostPort(serverIP, serverPort)

    // Connect to the SSH server
    sshConn, err := ssh.Dial("tcp", t, config)
    if err != nil {
        fmt.Printf("Failed to connect to %v\n", t)
        fmt.Println(err)
        os.Exit(2)
    }

    // Create new SSH session
    session, err := sshConn.NewSession()
    if err != nil {
        fmt.Printf("Cannot create SSH session to %v\n", t)
        fmt.Println(err)
        os.Exit(2)
    }

    // Close the session when main returns
    defer session.Close()

    // For an interactive session we must redirect IO
    session.Stdout = os.Stdout
    session.Stderr = os.Stderr
    input, err := session.StdinPipe()
    if err != nil {
        fmt.Println("Error redirecting session input", err)
        os.Exit(2)
    }

    // Setup terminal mode when requesting pty. You can see all terminal modes at
    // https://github.com/golang/crypto/blob/master/ssh/session.go#L56 or read
    // the RFC for explanation https://tools.ietf.org/html/rfc4254#section-8
    termModes := ssh.TerminalModes{
        ssh.ECHO: 0, // Disable echo
    }

    // Request pty
    // https://tools.ietf.org/html/rfc4254#section-6.2
    // First variable is term environment variable value which specifies terminal.
    // term doesn't really matter here, we will use "vt220".
    // Next are height and width: (40,80) characters and finall termModes.
    err = session.RequestPty("vt220", 40, 80, termModes)
    if err != nil {
        fmt.Println("RequestPty failed", err)
        os.Exit(2)
    }

    // Also
    // if err = session.RequestPty("vt220", 40, 80, termModes); err != nil {
    //  fmt.Println("RequestPty failed", err)
    //  os.Exit(2)
    // }

    // Now we can start a remote shell
    err = session.Shell()
    if err != nil {
        fmt.Println("shell failed", err)
        os.Exit(2)
    }

    // Same as above, a different way to check for errors
    // if err = session.Shell(); err != nil {
    //  fmt.Println("shell failed", err)
    //  os.Exit(2)
    // }

    // Endless loop to capture commands
    // Note: After exit, we need to ctrl+c to end the application.
    for {
        io.Copy(input, os.Stdin)
    }
}

First we create a config (note it's a pointer):

// Create SSH config
config := &ssh.ClientConfig{
    // Username
    User: username,
    // Each config must have one AuthMethod. In this case we use password
    Auth: []ssh.AuthMethod{
        ssh.Password(password),
    },
    // This callback function validates the server.
    // Danger! We are ignoring host info
    HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

Each config should have an AuthMethod. We are using a password in this program.

Next on the config is HostKeyCallback and is used to verify the server.

The familiar Dial method connects to the server. Then we create a session (each connection can have multiple sessions).

We set stdin, stdout and stderr for session and then terminal modes. Finally we request a pseudo-terminal with RequestPty and a shell. We capture commands on stdin by basically copying os.Stdin to the connection's input.

Note: Depending on your SSH server and the terminal mode, you might see color codes. For example you will see ANSI color codes if you run it from Windows cmd, but not in PowerShell. With Windows OpenSSH, it does not matter what TERM is sent, the color codes will not go away in cmd.

Verifying host

Usually when creating small programs in security, we do not care about the host. But it's always good to check.

HostKeyCallback in config can be used in three ways:

  • ssh.InsecureIgnoreHostKey(): Ignore everything!
  • ssh.FixedHostKey(key PublicKey): Returns a function to check the hostkey.
  • Custom host verifier: Return nil if host is ok, otherwise return an error.

ssh.FixedHostKey

This is an easy check. We pass a host key and the method checks if it matches the one returned by the connection.

// https://github.com/golang/crypto/blob/master/ssh/client.go#L265
// FixedHostKey returns a function for use in
// ClientConfig.HostKeyCallback to accept only a specific host key.
func FixedHostKey(key PublicKey) HostKeyCallback {
    hk := &fixedHostKey{key}
    return hk.check
}

Looking at the source, it just unmarshals two publickeys and checks if they match.

// https://github.com/golang/crypto/blob/master/ssh/client.go#L253
func (f *fixedHostKey) check(hostname string, remote net.Addr, key PublicKey) error {
    if f.key == nil {
        return fmt.Errorf("ssh: required host key was nil")
    }
    if !bytes.Equal(key.Marshal(), f.key.Marshal()) {
        return fmt.Errorf("ssh: host key mismatch")
    }
    return nil
}

It's straightforward to use. The new program is only a little different from the old one:

  • Create a variable of type ssh.PublicKey to hold the key.
  • Pass HostKeyCallback: ssh.FixedHostKey(var_from_above) in config.
// Define host's public key
var hostPubKey ssh.PublicKey

// Populate hostPubKey

// Create SSH config
config := &ssh.ClientConfig{
    // Username
    User: username,
    // Each config must have one AuthMethod. In this case we use password
    Auth: []ssh.AuthMethod{
        ssh.Password(password),
    },
    // Danger! We are ignoring host info
    HostKeyCallback: ssh.FixedHostKey(hostPubKey),
}

Custom host verifier

This has more flexibility. We can also use this callback function to grab and store a server's public key. It can have any number of arguments (usually we use these arguments to pass info to the host checker). It should return a function of type ssh.HostKeyCallback:

type HostKeyCallback func(hostname string, remote net.Addr, key PublicKey) error

In other words, it's a function of this type:

func hostChecker(arg1 type1, arg2 type2, ...) ssh.HostKeyCallback {
    // ...
}

Returned function can be a separate function or an anonymous function created inside hostChecker. Here's an example of an anonymous function used by InsecureIgnoreHostKey from ssh package's source:

// https://github.com/golang/crypto/blob/master/ssh/client.go#L240

// InsecureIgnoreHostKey returns a function that can be used for
// ClientConfig.HostKeyCallback to accept any host key. It should
// not be used for production code.
func InsecureIgnoreHostKey() HostKeyCallback {
    return func(hostname string, remote net.Addr, key PublicKey) error {
        return nil
    }
}

Now we know enough to create our own custom host checker and pass it to HostKeyCallback:

// 04.4-02-sshclient-check-host.go

// hostChecker returns a function to be used as callback for HostKeyCallback.
func hostChecker() ssh.HostKeyCallback {
    return printServerKey
}

// printServerKey prints server's info instead of checking it.
// It's of type HostKeyCallback
func printServerKey(hostname string, remote net.Addr, key ssh.PublicKey) error {
    // Just print everything
    fmt.Printf("Hostname: %v\nRemote address: %v\nServer key: %+v\n",
        hostname, remote, key)
    // Return nil so connection can continue without checking the server
    return nil
}

We can see server info in the callback function:

$ go run 04.4-02-sshclient2.go -user user -pass 12345
Hostname: 127.0.0.1:22
Remote address: 127.0.0.1:22
Server key: &{Curve:{CurveParams:0xc04204e100}
X:+95446563830190539723549646387134804373421025763629370453495481728809028570967
Y:+71690030922286766932148563959160819051208718262353076812036347925006921654863}
...

Login with SSH key

It's also possible to pass another AuthMethod and login with a key. Luckily, the package has another example. We read the PEM encoded private key and use it in ClientConfig.

// 04.4-03-sshclient-login-key.go

// Now we must read the private key
pKey, err := ioutil.ReadFile(pKeyFile)
if err != nil {
    fmt.Println("Failed to read private key from file", err)
    os.Exit(2)
}

// Create a signer with the private key
signer, err := ssh.ParsePrivateKey(pKey)
if err != nil {
    fmt.Println("Failed to parse private key", err)
    os.Exit(2)
}

// Create SSH config
config := &ssh.ClientConfig{
    // Username
    User: username,
    // Each config must have one AuthMethod. Now we use key
    Auth: []ssh.AuthMethod{
        ssh.PublicKeys(signer),
    },
    // This callback function validates the server.
    // Danger! We are ignoring host info
    HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}

Login and run a command

Interactive login is useful but there are SSH clients for that. Automated tools usually want to login, run commands, capture the output and move on to the next host.

Each session can only run one command. A new session must be created for each new command (one SSH connection can support multiple sessions). We can run commands using one of these methods:

  1. Not all of these methods return the output directly (in []byte). For those that do not, we need to read session.Stdout/Stderr.
  2. All of them return errors. After execution, check the errors.
  3. For obvious reasons, it seems like CombinedOutput will work best.

Run a command with CombinedOutput

We re-use the code from first example but stop after the session is created. Then we run the command, check for errors and print the output.

// 04.4-04-sshclient-run-combinedoutput.go

// Close the session when main returns
defer session.Close()

// Run a command with CombinedOutput
o, err := session.CombinedOutput(command)
if err != nil {
    fmt.Println("Error running command", err)
}

fmt.Printf("Output:\n%s", o)

Results from my VM (don't get excited, it's the default user/pass for https://modern.ie VMs):

$ go run .\04.4-04-sshclient-run-combinedoutput.go -user IEUser -pass Passw0rd! -cmd dir
Output:
 Volume in drive C is Windows 10
 Volume Serial Number is C436-9552

 Directory of C:\Users\IEUser

12/19/2017  08:28 PM    <DIR>          .
12/19/2017  08:28 PM    <DIR>          ..
10/02/2017  12:50 AM    <DIR>          .gradle
12/24/2017  07:02 PM    <DIR>          .ssh
03/23/2017  12:29 PM                 6 .vbox_version
03/23/2017  11:18 AM    <DIR>          Contacts
12/24/2017  01:50 AM    <DIR>          Desktop
...

Of course, we can always cheat by running multiple commands. On Windows use & and &&.

$ go run .\04.4-04-sshclient-run-combinedoutput.go -user IEUser -pass Passw0rd! -cmd "cd .. && dir"
Output:
 Volume in drive C is Windows 10
 Volume Serial Number is C436-9552

 Directory of C:\Users

12/24/2017  01:53 AM    <DIR>          .
12/24/2017  01:53 AM    <DIR>          ..
12/19/2017  08:28 PM    <DIR>          IEUser
03/23/2017  11:18 AM    <DIR>          Public
12/24/2017  01:53 AM    <DIR>          SSHD
               0 File(s)              0 bytes
               5 Dir(s)  21,863,829,504 bytes free

Run a command with Run

Using Run is similar, we buffer session.Stdout/Stderr before we execute the command and print them after. This is based on the package example:

// 04.4-05-sshclient-run-run.go

// Close the session when main returns
defer session.Close()

// Create buffers for stdout and stderr
var o, e bytes.Buffer

session.Stdout = &o
session.Stderr = &e

// Run a command with Run and read stdout and stderr
if err := session.Run(command); err != nil {
    fmt.Println("Error running command", err)
}

// Convert buffer to string
fmt.Printf("stdout:\n%s\nstderr:\n%s", o.String(), e.String())

Continue reading ⇒ 04.5 - SSH Harvester