From 448a3895156d77dabe9786ce73ac2c4353b94724 Mon Sep 17 00:00:00 2001 From: Michael Stapelberg Date: Sat, 4 Mar 2017 11:09:10 +0100 Subject: [PATCH] Initial commit --- LICENSE | 27 ++++++ README.md | 63 ++++++++++++++ breakglass.go | 146 ++++++++++++++++++++++++++++++++ scp.go | 124 +++++++++++++++++++++++++++ ssh.go | 227 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 587 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 breakglass.go create mode 100644 scp.go create mode 100644 ssh.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97a080f --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2017 the gokrazy authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of gokrazy nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fbc1bc0 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# breakglass + +breakglass is a [gokrazy](https://github.com/gokrazy/gokrazy) package +which provides emergency/debugging access to a gokrazy installation. + +It breaks the gokrazy model in that it allows you to run payloads +implemented in any language (e.g. busybox, implemented in C). + +To repeat, breakglass’s whole idea is **remote code execution** (via +SSH/SCP, listening only on private network addresss). Hence, it should +usually not be present on your gokrazy installation, but it might be +useful for development/debugging. As a safety measure, breakglass will +not automatically be started on boot, but needs to explicitly be +started via the gokrazy web interface. + +## Installation + +Add the `github.com/gokrazy/breakglass` package to your `gokr-packer` +command, e.g.: + +``` +gokr-packer -overwrite=/dev/sdb \ + github.com/gokrazy/hello \ + github.com/gokrazy/breakglass +``` + +On the permanent file system of your gokrazy installation, create a +host key and an authorized keys file. Assuming you mounted the +permanent file system at `/media/sdb4`: + +``` +sudo ssh-keygen -N '' -t rsa -f /media/sdb4/breakglass.host_key +sudo install -m 600 ~/.ssh/authorized_keys /media/sdb4/breakglass.authorized_keys +``` + +## Usage + +1. Create a tarball containing your statically linked arm64 binaries + and any other files you’ll need. +2. SCP that tarball to your gokrazy installation, where breakglass + will unpack it into a temporary directory. +3. Execute a binary via SSH. + +Here’s an example, assuming you unpacked and statically cross-compiled +busybox in `/tmp/busybox-1.22.0` and your gokrazy installation runs on +host `gokrazy`: + +``` +$ cd /tmp/busybox-1.22.0 +$ file busybox +busybox: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, +for GNU/Linux 3.7.0, BuildID[sha1]=c9e20e9849ed0ca3c2bd058427ac31a27c008efe, stripped +$ tar cf breakglass.tar busybox +$ scp breakglass.tar gokrazy: +$ ssh gokrazy -t ./busybox sh +/tmp/breakglass564067692 # df -h +Filesystem Size Used Available Use% Mounted on +/dev/root 60.5M 60.5M 0 100% / +devtmpfs 445.3M 0 445.3M 0% /dev +tmpfs 50.0M 1.8M 48.2M 4% /tmp +tmpfs 1.0M 8.0K 1016.0K 1% /etc +/dev/mmcblk0p4 28.2G 44.1M 26.7G 0% /perm +``` \ No newline at end of file diff --git a/breakglass.go b/breakglass.go new file mode 100644 index 0000000..31ac28e --- /dev/null +++ b/breakglass.go @@ -0,0 +1,146 @@ +// breakglass is a SSH/SCP server which unpacks received tar archives +// and allows to run commands in the unpacked archive. +package main + +import ( + "flag" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "syscall" + + "github.com/gokrazy/gokrazy" + + "golang.org/x/crypto/ssh" +) + +var ( + authorizedKeysPath = flag.String("authorized_keys", + "/perm/breakglass.authorized_keys", + "path to an OpenSSH authorized_keys file") + + hostKeyPath = flag.String("host_key", + "/perm/breakglass.host_key", + "path to a PEM-encoded RSA, DSA or ECDSA private key (create using e.g. ssh-keygen -f /perm/breakglass.host_key -N '' -t rsa)") +) + +func loadAuthorizedKeys(path string) (map[string]bool, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + result := make(map[string]bool) + + for len(b) > 0 { + pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(b) + if err != nil { + return nil, err + } + result[string(pubKey.Marshal())] = true + b = rest + } + + return result, nil +} + +func loadHostKey(path string) (ssh.Signer, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, err + } + + return ssh.ParsePrivateKey(b) +} + +func main() { + flag.Parse() + log.SetFlags(log.LstdFlags | log.Lshortfile) + + gokrazy.DontStartOnBoot() + + authorizedKeys, err := loadAuthorizedKeys(*authorizedKeysPath) + if err != nil { + log.Fatal(err) + } + + config := &ssh.ServerConfig{ + PublicKeyCallback: func(conn ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) { + if authorizedKeys[string(pubKey.Marshal())] { + log.Printf("user %q successfully authorized from remote addr %s", conn.User(), conn.RemoteAddr()) + return nil, nil + } + return nil, fmt.Errorf("public key not found in %s", *authorizedKeysPath) + }, + } + + signer, err := loadHostKey(*hostKeyPath) + if err != nil { + log.Fatal(err) + } + config.AddHostKey(signer) + + unpackDir, err := ioutil.TempDir("", "breakglass") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(unpackDir) + + // This tmpfs mount ensures that our temp directory is mounted + // without NOEXEC and that we have plenty of space for payload. + // It will be cleaned up on process exit because each gokrazy + // process uses a non-shared mount namespace. + if err := syscall.Mount("tmpfs", unpackDir, "tmpfs", syscall.MS_NOSUID|syscall.MS_NODEV|syscall.MS_RELATIME, "size=500M"); err != nil { + log.Fatal("tmpfs on %s: %v", unpackDir, err) + } + + if err := os.Chdir(unpackDir); err != nil { + log.Fatal(err) + } + + accept := func(listener net.Listener) { + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("accept: %v", err) + continue + } + + go func(conn net.Conn) { + _, chans, reqs, err := ssh.NewServerConn(conn, config) + if err != nil { + log.Printf("handshake: %v", err) + return + } + + // discard all out of band requests + go ssh.DiscardRequests(reqs) + + for newChannel := range chans { + handleChannel(newChannel) + } + }(conn) + } + } + + addrs, err := gokrazy.PrivateInterfaceAddrs() + if err != nil { + log.Fatal(err) + } + + for _, addr := range addrs { + hostport := net.JoinHostPort(addr, "22") + listener, err := net.Listen("tcp", hostport) + if err != nil { + log.Fatal(err) + } + fmt.Printf("listening on %s\n", hostport) + go accept(listener) + } + + fmt.Printf("host key fingerprint: %s\n", ssh.FingerprintSHA256(signer.PublicKey())) + + select {} +} diff --git a/scp.go b/scp.go new file mode 100644 index 0000000..7bb0d35 --- /dev/null +++ b/scp.go @@ -0,0 +1,124 @@ +package main + +import ( + "archive/tar" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/crypto/ssh" +) + +type countingWriter int64 + +func (cw *countingWriter) Write(p []byte) (n int, err error) { + *cw += countingWriter(len(p)) + return len(p), nil +} + +func scpSink(channel ssh.Channel, req *ssh.Request, cmdline []string) error { + scpFlags := flag.NewFlagSet("scp", flag.ContinueOnError) + sink := scpFlags.Bool("t", false, "sink (to)") + if err := scpFlags.Parse(cmdline[1:]); err != nil { + return err + } + if !*sink { + return fmt.Errorf("expected -t") + } + + // Tell the remote end we’re ready to receive data. + if _, err := channel.Write([]byte{0x00}); err != nil { + return err + } + + buf := make([]byte, 1024) + for { + n, err := channel.Read(buf) + if err == io.EOF { + break + } + if err != nil { + return err + } + msg := buf[:n] + + // Acknowledge receipt of the control message + if _, err := channel.Write([]byte{0x00}); err != nil { + return err + } + + if msg[0] == 'C' { + msgstr := strings.TrimSpace(string(msg)) + parts := strings.Split(msgstr, " ") + if got, want := len(parts), 3; got != want { + return fmt.Errorf("invalid number of space-separated tokens in control message %q: got %d, want %d", msgstr, got, want) + } + size, err := strconv.ParseInt(parts[1], 0, 64) + if err != nil { + return err + } + + // Retrieve file contents + var cw countingWriter + tr := tar.NewReader(io.TeeReader(channel, &cw)) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + log.Printf("extracting %q", h.Name) + if err := os.MkdirAll(filepath.Dir(h.Name), 0700); err != nil { + return err + } + mode := h.FileInfo().Mode() & os.ModePerm + out, err := os.OpenFile(h.Name, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + } + + if rest := size - int64(cw); rest > 0 { + buf := make([]byte, rest) + if _, err := channel.Read(buf); err != nil { + return err + } + } + + // Read status byte after transfer + buf := make([]byte, 1) + if _, err := channel.Read(buf); err != nil { + return err + } + + // Acknowledge file transfer + if _, err := channel.Write([]byte{0x00}); err != nil { + return err + } + } + } + + exitStatus := make([]byte, 4) + exitStatus[3] = 0 + if _, err := channel.SendRequest("exit-status", false, exitStatus); err != nil { + return err + } + channel.Close() + req.Reply(true, nil) + return nil +} diff --git a/ssh.go b/ssh.go new file mode 100644 index 0000000..a990a25 --- /dev/null +++ b/ssh.go @@ -0,0 +1,227 @@ +package main + +import ( + "encoding/binary" + "fmt" + "io" + "log" + "os" + "os/exec" + "sync" + "syscall" + "unsafe" + + "github.com/google/shlex" + "github.com/kr/pty" + "golang.org/x/crypto/ssh" +) + +func handleChannel(newChannel ssh.NewChannel) { + if t := newChannel.ChannelType(); t != "session" { + newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %q", t)) + return + } + + channel, requests, err := newChannel.Accept() + if err != nil { + log.Printf("Could not accept channel (%s)", err) + return + } + + // Sessions have out-of-band requests such as "shell", "pty-req" and "env" + go func(channel ssh.Channel, requests <-chan *ssh.Request) { + s := session{channel: channel} + for req := range requests { + if err := s.request(req); err != nil { + errmsg := []byte(err.Error()) + // Append a trailing newline; the error message is + // displayed as-is by ssh(1). + if errmsg[len(errmsg)-1] != '\n' { + errmsg = append(errmsg, '\n') + } + req.Reply(false, errmsg) + channel.Write(errmsg) + channel.Close() + } + } + }(channel, requests) +} + +type session struct { + env []string + ptyf *os.File + ttyf *os.File + channel ssh.Channel +} + +func stringFromPayload(payload []byte, offset int) (string, int, error) { + if got, want := len(payload), offset+4; got < want { + return "", 0, fmt.Errorf("request payload too short: got %d, want >= %d", got, want) + } + namelen := binary.BigEndian.Uint32(payload[offset : offset+4]) + if got, want := len(payload), offset+4+int(namelen); got < want { + return "", 0, fmt.Errorf("request payload too short: got %d, want >= %d", got, want) + } + name := payload[offset+4 : offset+4+int(namelen)] + return string(name), offset + 4 + int(namelen), nil +} + +func (s *session) request(req *ssh.Request) error { + switch req.Type { + case "pty-req": + var err error + s.ptyf, s.ttyf, err = pty.Open() + if err != nil { + return err + } + _, next, err := stringFromPayload(req.Payload, 0) + if err != nil { + return err + } + if got, want := len(req.Payload), next+4+4; got < want { + return fmt.Errorf("request payload too short: got %d, want >= %d", got, want) + } + + w, h := parseDims(req.Payload[next:]) + SetWinsize(s.ptyf.Fd(), w, h) + // Responding true (OK) here will let the client + // know we have a pty ready for input + req.Reply(true, nil) + + case "window-change": + w, h := parseDims(req.Payload) + SetWinsize(s.ptyf.Fd(), w, h) + + case "env": + name, next, err := stringFromPayload(req.Payload, 0) + if err != nil { + return err + } + + value, _, err := stringFromPayload(req.Payload, next) + if err != nil { + return err + } + + s.env = append(s.env, fmt.Sprintf("%s=%s", name, value)) + + case "shell": + // as per https://tools.ietf.org/html/rfc4254#section-6.5, + // shell requests don’t carry a payload, and we don’t have a + // default shell, so decline the request + return fmt.Errorf("shell requests unsupported, use exec") + + case "exec": + if got, want := len(req.Payload), 4; got < want { + return fmt.Errorf("exec request payload too short: got %d, want >= %d", got, want) + } + + cmdline, err := shlex.Split(string(req.Payload[4:])) + if err != nil { + return err + } + + if cmdline[0] == "scp" { + return scpSink(s.channel, req, cmdline) + } + + cmd := exec.Command(cmdline[0], cmdline[1:]...) + cmd.Env = s.env + cmd.SysProcAttr = &syscall.SysProcAttr{} + + if s.ttyf == nil { + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + cmd.SysProcAttr.Setsid = true + + if err := cmd.Start(); err != nil { + return err + } + + go io.Copy(s.channel, stdout) + go io.Copy(s.channel, stderr) + go func() { + io.Copy(stdin, s.channel) + stdin.Close() + }() + + if err := cmd.Wait(); err != nil { + return err + } + + s.channel.Close() + return nil + } + + defer func() { + s.ttyf.Close() + s.ttyf = nil + }() + + cmd.Stdout = s.ttyf + cmd.Stdin = s.ttyf + cmd.Stderr = s.ttyf + cmd.SysProcAttr.Setctty = true + cmd.SysProcAttr.Setsid = true + + if err := cmd.Start(); err != nil { + s.ptyf.Close() + s.ptyf = nil + return err + } + + close := func() { + s.channel.Close() + cmd.Process.Wait() + } + + // pipe session to cmd and vice-versa + var once sync.Once + go func() { + io.Copy(s.channel, s.ptyf) + once.Do(close) + }() + go func() { + io.Copy(s.ptyf, s.channel) + once.Do(close) + }() + + req.Reply(true, nil) + + default: + return fmt.Errorf("unknown request type: %q", req.Type) + } + + return nil +} + +// parseDims extracts terminal dimensions (width x height) from the provided buffer. +func parseDims(b []byte) (uint32, uint32) { + w := binary.BigEndian.Uint32(b) + h := binary.BigEndian.Uint32(b[4:]) + return w, h +} + +// Winsize stores the Height and Width of a terminal. +type Winsize struct { + Height uint16 + Width uint16 + x uint16 // unused + y uint16 // unused +} + +// SetWinsize sets the size of the given pty. +func SetWinsize(fd uintptr, w, h uint32) { + ws := &Winsize{Width: uint16(w), Height: uint16(h)} + syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(ws))) +}