diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bb05612 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 90b8bbe..ba250ae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ result* # Binary files shieldbreak + +# Coverage output +coverage.out +coverage.txt diff --git a/Makefile b/Makefile index 8056245..8024bcc 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 8c50189..0f13cd6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..b923dd3 --- /dev/null +++ b/codecov.yml @@ -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 diff --git a/shielded-key-parser/parser/mem_region_test.go b/shielded-key-parser/parser/mem_region_test.go new file mode 100644 index 0000000..302a8a0 --- /dev/null +++ b/shielded-key-parser/parser/mem_region_test.go @@ -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) + } +} diff --git a/shielded-key-parser/parser/memory_dump_parser_test.go b/shielded-key-parser/parser/memory_dump_parser_test.go new file mode 100644 index 0000000..fb0001e --- /dev/null +++ b/shielded-key-parser/parser/memory_dump_parser_test.go @@ -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) + } +} diff --git a/shielded-key-parser/parser/ssh_key_reader_test.go b/shielded-key-parser/parser/ssh_key_reader_test.go new file mode 100644 index 0000000..b15dc24 --- /dev/null +++ b/shielded-key-parser/parser/ssh_key_reader_test.go @@ -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") + } +} diff --git a/shielded-key-parser/readelf/elf_manager_test.go b/shielded-key-parser/readelf/elf_manager_test.go new file mode 100644 index 0000000..ab8c64f --- /dev/null +++ b/shielded-key-parser/readelf/elf_manager_test.go @@ -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) + } +} diff --git a/test/run-test.sh b/test/run-test.sh new file mode 100755 index 0000000..e83c12b --- /dev/null +++ b/test/run-test.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# End-to-end test for shieldbreak. +# +# Generates an ssh-agent core dump with a known key, extracts and unshields the +# key with the tool, then verifies the recovered key is identical to the +# original by comparing SSH key fingerprints (which are independent of the key +# comment). Exits non-zero if the recovered key does not match. +# +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +KEY_TYPE="${KEY_TYPE:-rsa}" +KEY_COMMENT="${KEY_COMMENT:-test@key}" +DUMPS_DIR="$ROOT/dumps" +DUMP_FILE="id_${KEY_TYPE}.mem" +PLAINTEXT_FILE="${KEY_COMMENT}.plaintext" + +echo "[*] Building images" +docker build -t ssh-agent-test-dump-generator ./test/test-dump-generator +docker build -t shieldbreak . + +echo "[*] Generating test ssh-agent core dump" +mkdir -p "$DUMPS_DIR" +docker run --rm --privileged \ + -e KEY_TYPE="$KEY_TYPE" \ + -e KEY_COMMENT="$KEY_COMMENT" \ + -v "$DUMPS_DIR:/out" \ + ssh-agent-test-dump-generator + +echo "[*] Extracting and unshielding the key" +docker run --rm \ + -e KEY_COMMENT="$KEY_COMMENT" \ + -e DUMP_PATH="/data/${DUMP_FILE}" \ + -v "$DUMPS_DIR:/data:ro" \ + -v "$DUMPS_DIR:/out" \ + shieldbreak + +echo "[*] Verifying the recovered key matches the original" +# ssh-keygen is available in the generator image; run the comparison as root +# there so it can read the root-owned dump artifacts. +docker run --rm --entrypoint bash -v "$DUMPS_DIR:/d" ssh-agent-test-dump-generator -c ' + set -eu + chmod 600 "/d/id_'"$KEY_TYPE"'" "/d/'"$PLAINTEXT_FILE"'" + original=$(ssh-keygen -lf "/d/id_'"$KEY_TYPE"'" | cut -d" " -f2) + recovered=$(ssh-keygen -lf "/d/'"$PLAINTEXT_FILE"'" | cut -d" " -f2) + echo " original fingerprint: $original" + echo " recovered fingerprint: $recovered" + if [ "$original" != "$recovered" ]; then + echo "[!] FAIL: recovered key does not match the original" >&2 + exit 1 + fi +' + +echo "[+] PASS: recovered key is identical to the original"