Initial commit

This commit is contained in:
Michael Stapelberg 2017-03-04 11:09:10 +01:00
commit 448a389515
5 changed files with 587 additions and 0 deletions

27
LICENSE Normal file
View File

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

63
README.md Normal file
View File

@ -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, breakglasss 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 youll need.
2. SCP that tarball to your gokrazy installation, where breakglass
will unpack it into a temporary directory.
3. Execute a binary via SSH.
Heres 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
```

146
breakglass.go Normal file
View File

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

124
scp.go Normal file
View File

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

227
ssh.go Normal file
View File

@ -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 dont carry a payload, and we dont 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)))
}