Initial commit
This commit is contained in:
commit
448a389515
27
LICENSE
Normal file
27
LICENSE
Normal 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
63
README.md
Normal 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, 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
|
||||
```
|
146
breakglass.go
Normal file
146
breakglass.go
Normal 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
124
scp.go
Normal 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 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
|
||||
}
|
227
ssh.go
Normal file
227
ssh.go
Normal 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 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)))
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user