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
4 changes: 3 additions & 1 deletion .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ builds:
- -s -w
- -X main.version={{ .Version }}

# Payload packer. No version stamp — it is stateless tooling.
# Payload packer. Stateless, but stamped anyway so a shipped copy can
# identify itself when an operator reports a problem with it.
- id: oblpack
main: ./cmd/oblpack
binary: oblpack
Expand All @@ -53,6 +54,7 @@ builds:
- arm64
ldflags:
- -s -w
- -X main.version={{ .Version }}

archives:
- id: default
Expand Down
5 changes: 4 additions & 1 deletion cmd/obliteratus/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import (
// is a var, not a const, so goreleaser can stamp the tag into it at link time
// via -ldflags "-X main.version={{.Version}}" (ldflags cannot write a const).
// The literal below is the value used by `go build`/`go run` outside a release.
var version = "2.0.1"
//
// Keep it equal to the newest tag: a from-source build that reports an older
// release than the published one makes bug reports lie.
var version = "2.0.2"

func main() {
// Hidden internal mode: the collector re-launches this binary as SYSTEM
Expand Down
11 changes: 11 additions & 0 deletions cmd/oblpack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ import (
"github.com/DevCop95/obliteratus/internal/research"
)

// version is stamped by goreleaser (see the oblpack build in .goreleaser.yml)
// and is what `-version` reports. The literal covers a plain `go build`, and
// must track the newest tag for the same reason as cmd/obliteratus.
var version = "2.0.2"

func main() {
key := flag.String("key", "", "packing passphrase (hashed to a 32-byte AES-256 key)")
in := flag.String("in", "payloads/research_signal.bin", "input payload file")
out := flag.String("out", "", "output file (default: <in>.packed)")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()

if *showVersion {
fmt.Printf("oblpack %s\n", version)
return
}

if *key == "" {
fatalf("-key is required")
}
Expand Down
33 changes: 30 additions & 3 deletions internal/persist/persist_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package persist

import (
"bytes"
"context"
"encoding/csv"
"fmt"
"io"
Expand All @@ -12,6 +13,7 @@ import (
"path/filepath"
"strings"
"syscall"
"time"

"golang.org/x/sys/windows/registry"
)
Expand Down Expand Up @@ -155,12 +157,37 @@ func removeStartup(name string) error {

const taskPrefix = "OBL_"

// runHiddenBudget bounds every child process this module spawns. The Task
// Scheduler and WMI are the classic Windows hang: schtasks can wedge for
// minutes behind a stuck Task Scheduler or RPCSS, and exec.Command waits with
// no deadline at all, which used to pin an HTTP handler (and its goroutine)
// for as long as the wedge lasted. Measured on a stock workstation a full
// List() takes ~5.5s, so this is a liveness bound rather than a latency target.
//
// It is a var only so a test can shrink it; nothing mutates it at runtime.
var runHiddenBudget = 20 * time.Second

// runHiddenWaitDelay bounds how long Wait may keep draining the child's pipes
// after the context is done. Killing `cmd /c …` or powershell does not kill
// what they spawned, and the inherited pipe stays open until that grandchild
// exits — without this, the timeout would still block on the pipe read.
const runHiddenWaitDelay = 2 * time.Second

// runHidden runs a child process with no visible window and returns its
// combined output.
// combined output within runHiddenBudget.
func runHidden(name string, args ...string) ([]byte, error) {
cmd := exec.Command(name, args...) //#nosec G204 -- name/args are internal constants and generated task names, never operator input
ctx, cancel := context.WithTimeout(context.Background(), runHiddenBudget)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...) //#nosec G204 -- name/args are internal constants and generated task names, never operator input
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.CombinedOutput()
cmd.WaitDelay = runHiddenWaitDelay
out, err := cmd.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
// Name the cause: "schtasks exit status 1" and "schtasks never came
// back" are different problems for whoever is debugging.
return out, fmt.Errorf("%s: timed out after %s", name, runHiddenBudget)
}
return out, err
}

func addTask(name, command, schedule, startTime string) error {
Expand Down
30 changes: 30 additions & 0 deletions internal/persist/persist_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package persist
import (
"strings"
"testing"
"time"
)

func TestPsQuote(t *testing.T) {
Expand Down Expand Up @@ -120,3 +121,32 @@ func TestWMIRemoveCandidates(t *testing.T) {
t.Fatalf("wmiRemoveCandidates(prefixed) = %v, want a single entry", got)
}
}

// TestRunHiddenTimesOut pins the liveness bound. Before it, a wedged schtasks
// or WMI call held the HTTP handler forever, because exec.Command waits with
// no deadline.
//
// The sleep has to be a real child process: `timeout /t` is unusable here
// because it refuses to run without a console, which is exactly how runHidden
// spawns its children. `ping -n 10` occupies the child for ~9s, so a 300ms
// budget leaves a wide margin between "the context fired" and "the child would
// have finished anyway".
func TestRunHiddenTimesOut(t *testing.T) {
prev := runHiddenBudget
runHiddenBudget = 300 * time.Millisecond
defer func() { runHiddenBudget = prev }()

start := time.Now()
_, err := runHidden("ping", "-n", "10", "127.0.0.1")
elapsed := time.Since(start)

if err == nil {
t.Fatal("runHidden returned a nil error for a child that outlived the budget")
}
if !strings.Contains(err.Error(), "timed out") {
t.Fatalf("want an explicit timeout error, got %v", err)
}
if elapsed > 4*time.Second {
t.Fatalf("runHidden returned after %s: the context was not enforced", elapsed)
}
}
18 changes: 13 additions & 5 deletions internal/server/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,19 @@ var loopbackHosts = map[string]bool{
// loopback with a numeric port is refused.
//
// Note that net.SplitHostPort validates the *shape* of an address, not its
// contents: it happily returns host "127.0.0.1" and port "8080/evil" for
// "127.0.0.1:8080/evil". Trusting it would accept host strings no parser ever
// meant to hand us, so the port is checked explicitly. The stdlib HTTP server
// rejects such a Host header before it reaches a handler, which is why this is
// hardening rather than a live bypass — but a guard should mean what it says.
// contents, and it is more permissive than it looks: for "127.0.0.1:8080/evil"
// it returns host "127.0.0.1" with port "8080/evil", and for "127.0.0.1:8a08"
// and "127.0.0.1:" it returns no error at all. The stdlib HTTP server only
// rejects the first shape — a Host containing "/" never reaches a handler (400
// before this code runs) — so for a non-numeric or empty port this explicit
// isPort check is the only control, not a belt over the stdlib's braces. That
// was verified against the live listener, not assumed.
//
// None of the three is a live bypass: a rebinding page cannot make a browser
// send a loopback-looking Host, it supplies its own hostname. But the guard is
// asserted here rather than left to the stdlib, and those exact host strings
// are pinned in the fuzz seed corpus (FuzzValidHost), so the check cannot
// quietly become permissive again.
func validHost(hostport string) bool {
host := hostport
if h, p, err := net.SplitHostPort(hostport); err == nil {
Expand Down
Loading