Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: CI

on:
push:
branches: [ master ]
pull_request:

jobs:
unit:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version-file: shielded-key-parser/go.mod
cache-dependency-path: shielded-key-parser/go.sum

- name: Run tests with coverage
working-directory: shielded-key-parser
run: go test -race -covermode=atomic -coverprofile=../coverage.out ./...

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: Saggre/shieldbreak
files: ./coverage.out
fail_ci_if_error: false

integration:
name: Integration (end-to-end)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

# Builds a debug ssh-keygen and the parser image, generates an ssh-agent
# core dump, extracts and unshields the key, and verifies it matches.
- name: Run end-to-end test
run: make test
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ result*

# Binary files
shieldbreak

# Coverage output
coverage.out
coverage.txt
33 changes: 25 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
.PHONY: test clean build build-test-dump-generator
.PHONY: test test-unit test-cover test-all clean build build-go-app run-go-app \
build-shieldbreak-image run-shieldbreak-image \
build-test-dump-generator run-test-dump-generator

# Run tests
# Run every test: fast structural unit tests, then the end-to-end test.
test-all: test-unit test

# Run the structural Go unit tests.
test-unit:
cd shielded-key-parser && go test ./...

# Run the unit tests with coverage and write a profile plus a summary.
test-cover:
cd shielded-key-parser && go test -covermode=atomic -coverprofile=../coverage.out ./...
go tool cover -func=coverage.out | tail -n 1

# Run the end-to-end test: generate a dump, extract and unshield the key,
# and verify the recovered key matches the original.
test:
./test-dump-generator/dump.sh --rebuild
./test/run-test.sh

# Clean test dump files
# Clean test dump files and coverage output
clean:
rm -f ./dumps/*
rm -f ./coverage.out ./coverage.txt

build: build-go-app build-test-dump-generator build-shieldbreak-image
rm -f shieldbreak
go build -o shieldbreak
# Build the container images the tool runs in. Everything is compiled inside
# the images, so no Go or OpenSSH toolchain is needed on the host.
build: build-test-dump-generator build-shieldbreak-image

# Build the parser binary on the host (optional, for local development).
build-go-app:
rm -f shieldbreak
go build -o shieldbreak
go build -C shielded-key-parser -o ../shieldbreak

run-go-app:
./shieldbreak
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# shieldbreak

[![CI](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml/badge.svg)](https://github.com/Saggre/shieldbreak/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/Saggre/shieldbreak/branch/master/graph/badge.svg)](https://codecov.io/gh/Saggre/shieldbreak)

A tool for extracting OpenSSH `ssh-agent` **shielded** private keys from a
process **core dump**, offline, on x86_64 Linux.

Expand Down Expand Up @@ -85,6 +88,32 @@ docker run --rm \
`KEY_COMMENT` is the comment string of the target key, used to locate it in the
dump. The recovered plaintext key is written to the mounted `/out` directory.

## Testing

The parser has structural unit tests (Go's `testing` package) covering the ELF
segment math and the `sshkey` struct offset parsing, plus an end-to-end test
that generates a real `ssh-agent` core dump, extracts the key, and verifies the
recovered key matches the original by fingerprint.

```sh
# Fast structural unit tests
make test-unit

# Unit tests with a coverage profile and summary
make test-cover

# End-to-end test: generate a dump, extract, unshield, and verify recovery
make test

# Both unit and end-to-end
make test-all
```

Continuous integration runs on GitHub Actions (`.github/workflows/ci.yml`): the
unit tests run with the race detector and coverage on every push and pull
request, coverage is uploaded to [Codecov](https://codecov.io/) (the badge
above), and the end-to-end test runs as a separate job.

## Intended use

For research, education, and authorised testing only, for example
Expand Down
10 changes: 10 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Coverage is reported for information only and never fails CI. This is a small
# research tool, so a hard coverage gate would add noise without much value.
coverage:
status:
project:
default:
informational: true
patch:
default:
informational: true
13 changes: 13 additions & 0 deletions shielded-key-parser/parser/mem_region_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package parser

import "testing"

func TestNewMemRegion(t *testing.T) {
r := NewMemRegion(0x1000, 0x2000)
if r.Start != 0x1000 {
t.Errorf("Start = 0x%x, want 0x1000", r.Start)
}
if r.End != 0x2000 {
t.Errorf("End = 0x%x, want 0x2000", r.End)
}
}
52 changes: 52 additions & 0 deletions shielded-key-parser/parser/memory_dump_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package parser

import (
"encoding/binary"
"reflect"
"testing"
)

func TestFindOccurrences(t *testing.T) {
p := NewMemoryDumpParser()

cases := []struct {
name string
mem string
needle string
want []uint64
}{
{"repeated overlapping-free", "ababXabab", "ab", []uint64{0, 2, 5, 7}},
{"single match", "hello world", "world", []uint64{6}},
{"no match", "hello world", "zzz", nil},
{"overlapping needle", "aaaa", "aa", []uint64{0, 1, 2}},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.findOccurrences([]byte(tc.mem), []byte(tc.needle))
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("findOccurrences(%q, %q) = %v, want %v", tc.mem, tc.needle, got, tc.want)
}
})
}
}

func TestReadUint64(t *testing.T) {
p := NewMemoryDumpParser()
mem := make([]byte, 16)
binary.LittleEndian.PutUint64(mem[4:], 0xdeadbeefcafef00d)

if got := p.readUint64(mem, 4); got != 0xdeadbeefcafef00d {
t.Fatalf("readUint64 = 0x%x, want 0xdeadbeefcafef00d", got)
}
}

func TestReadUint32(t *testing.T) {
p := NewMemoryDumpParser()
mem := make([]byte, 16)
binary.LittleEndian.PutUint32(mem[8:], 0x11223344)

if got := p.readUint32(mem, 8); got != 0x11223344 {
t.Fatalf("readUint32 = 0x%x, want 0x11223344", got)
}
}
91 changes: 91 additions & 0 deletions shielded-key-parser/parser/ssh_key_reader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package parser

import (
"bytes"
"encoding/binary"
"testing"

"shieldbreak/readelf"
)

// buildKeyDump synthesises a memory image laid out like a real ssh-agent dump:
// an sshkey struct at keyOffset whose shielding fields (at ShieldedPrivatePtrOffset)
// point at a private-key blob and a prekey blob elsewhere in the buffer. The
// segment maps virtual address == file offset (Vaddr and Offset both 0) so the
// pointers can be plain buffer indices.
func buildKeyDump(prekeyLen uint64) (data []byte, keyOffset uint64, seg *readelf.Segment, priv, pre []byte) {
seg = &readelf.Segment{Offset: 0, Vaddr: 0, Filesz: 0x100000}
data = make([]byte, 0x9000)
keyOffset = 0x100

priv = []byte("SHIELDED-PRIVATE-KEY-BLOB")
pre = bytes.Repeat([]byte{0xAB}, 16384)

privOff := uint64(0x2000)
preOff := uint64(0x4000)
copy(data[privOff:], priv)
copy(data[preOff:], pre)

base := keyOffset + defaultOffsets.ShieldedPrivatePtrOffset
binary.LittleEndian.PutUint64(data[base:], privOff) // shielded_private ptr
binary.LittleEndian.PutUint64(data[base+8:], uint64(len(priv))) // shielded_len
binary.LittleEndian.PutUint64(data[base+16:], preOff) // shield_prekey ptr
binary.LittleEndian.PutUint64(data[base+24:], prekeyLen) // shield_prekey_len

return data, keyOffset, seg, priv, pre
}

func TestReadAtOffset_Success(t *testing.T) {
data, keyOffset, seg, priv, pre := buildKeyDump(16384)

gotPriv, gotPre, err := NewSshKeyReader().ReadAtOffset(data, keyOffset, seg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Equal(gotPriv, priv) {
t.Errorf("private blob = %q, want %q", gotPriv, priv)
}
if len(gotPre) != 16384 {
t.Errorf("prekey length = %d, want 16384", len(gotPre))
}
if !bytes.Equal(gotPre, pre) {
t.Errorf("prekey blob does not match expected contents")
}
}

func TestReadAtOffset_InvalidPrekeyLen(t *testing.T) {
data, keyOffset, seg, _, _ := buildKeyDump(1234) // not 16384

if _, _, err := NewSshKeyReader().ReadAtOffset(data, keyOffset, seg); err == nil {
t.Fatal("expected error for invalid prekey length, got nil")
}
}

func TestReadAtOffset_PrivatePointerOutOfRange(t *testing.T) {
data, keyOffset, seg, _, pre := buildKeyDump(16384)

// Point shielded_private outside the buffer; the prekey pointer stays valid.
base := keyOffset + defaultOffsets.ShieldedPrivatePtrOffset
binary.LittleEndian.PutUint64(data[base:], uint64(len(data))+0x1000)

gotPriv, gotPre, err := NewSshKeyReader().ReadAtOffset(data, keyOffset, seg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotPriv != nil {
t.Errorf("expected nil private blob for out-of-range pointer, got %d bytes", len(gotPriv))
}
if !bytes.Equal(gotPre, pre) {
t.Errorf("prekey blob should still be recovered when only the private pointer is bad")
}
}

func TestReadAtOffset_OffsetExceedsData(t *testing.T) {
seg := &readelf.Segment{Offset: 0, Vaddr: 0, Filesz: 0x100000}
data := make([]byte, 0x80)

// keyOffset + ShieldedPrivatePtrOffset lands past the end of data.
if _, _, err := NewSshKeyReader().ReadAtOffset(data, 0x80, seg); err == nil {
t.Fatal("expected error when key offset exceeds data length, got nil")
}
}
67 changes: 67 additions & 0 deletions shielded-key-parser/readelf/elf_manager_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package readelf

import (
"bytes"
"testing"
)

func TestCalculateVA(t *testing.T) {
e := NewElfManager()

// va = pVaddr + (fileOffset - pOffset)
got := e.CalculateVA(0x1500, 0x1000, 0x400000)
want := uint64(0x400500)
if got != want {
t.Fatalf("CalculateVA = 0x%x, want 0x%x", got, want)
}
}

func TestFindSegmentForOffset(t *testing.T) {
e := NewElfManager()
segs := []Segment{
{Offset: 0x0000, Vaddr: 0x400000, Filesz: 0x1000},
{Offset: 0x2000, Vaddr: 0x600000, Filesz: 0x1000},
}

cases := []struct {
name string
offset uint64
wantVaddr uint64
wantErr bool
}{
{"start of first", 0x0000, 0x400000, false},
{"inside first", 0x0800, 0x400000, false},
{"end of first is exclusive", 0x1000, 0, true},
{"gap between segments", 0x1800, 0, true},
{"inside second", 0x2500, 0x600000, false},
{"past all segments", 0x9999, 0, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
seg, err := e.FindSegmentForOffset(segs, tc.offset)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for offset 0x%x, got segment %+v", tc.offset, seg)
}
return
}
if err != nil {
t.Fatalf("unexpected error for offset 0x%x: %v", tc.offset, err)
}
if seg.Vaddr != tc.wantVaddr {
t.Fatalf("segment Vaddr = 0x%x, want 0x%x", seg.Vaddr, tc.wantVaddr)
}
})
}
}

func TestToLittleEndian(t *testing.T) {
e := NewElfManager()

got := e.ToLittleEndian(0x0102030405060708)
want := []byte{0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}
if !bytes.Equal(got, want) {
t.Fatalf("ToLittleEndian = % x, want % x", got, want)
}
}
Loading
Loading