mirror of
https://github.com/mandiant/gopacket
synced 2026-06-21 13:57:02 +00:00
dfe4d2ea7b
Two compounding bugs in wmiexec / atexec / smbexec / dcomexec made the output-retrieval loop return early on any command that didn't finish within the first 100ms poll, leaking the temp file in ADMIN$ / C$. Issue #22 reports the symptom for wmiexec (systeminfo, ping); the same broken pattern existed in all four tools. 1. Wrong call site. The polling loop treated a sharing-violation on smbClient.Cat as the signal "command still running." Cat opens with FILE_SHARE_READ-compatible access — it succeeds against the writer on the first poll and returns the file as it currently exists (typically empty). The conflict actually lives on Rm: deleting the file requires DELETE access, which conflicts with the writer's share mode. Moved the sharing- violation check from Cat to Rm. Matches Impacket's wmiexec.py. 2. Dead string match. The check was strings.Contains(err.Error(), "STATUS_SHARING_VIOLATION"). The underlying smb2 library's NtStatus.Error() returns only the human-readable description ("A file cannot be opened because the share access flags are incompatible.") — the literal constant name never appears in err.Error(), so the check could never fire. Same issue for STATUS_OBJECT_NAME_NOT_FOUND in the not-found branch (further muddled by smb2/conn.go translating that NTSTATUS to os.ErrNotExist before wrapping). Added typed helpers in pkg/smb (IsSharingViolation, IsNotFound) that unwrap through *os.PathError and match on *smb2.ResponseError.Code plus the os.ErrNotExist sentinel. NTSTATUS values are hardcoded since the smb2/internal/erref package can't be imported from outside the smb2 tree. The dcomexec "broken / connection reset / use of closed" branch stays string-matched — those errors come from net, not smb2. Thanks to @aimogging in #22 for the diagnosis and proof-of-concept in #29; this change applies the same Cat-vs-Rm insight across all four exec tools and replaces the err.Error() substring matching with typed-error helpers so future smb2 library changes don't silently break the check again. Verified against GOAD winterfell: wmiexec whoami / systeminfo / ping -n 4 1.1.1.1 all return full output, exit 0, no ADMIN$ residue, both directly from the operator box and over SOCKS5H proxy.
362 lines
9.5 KiB
Go
362 lines
9.5 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mandiant/gopacket/pkg/dcerpc"
|
|
"github.com/mandiant/gopacket/pkg/dcerpc/tsch"
|
|
"github.com/mandiant/gopacket/pkg/flags"
|
|
"github.com/mandiant/gopacket/pkg/session"
|
|
"github.com/mandiant/gopacket/pkg/smb"
|
|
)
|
|
|
|
var (
|
|
noOutput = flag.Bool("nooutput", false, "Don't retrieve command output")
|
|
codec = flag.String("codec", "", "Output encoding (e.g., cp850, utf-8)")
|
|
timeout = flag.Int("timeout", 30, "timeout in seconds waiting for command output")
|
|
silentCommand = flag.Bool("silentcommand", false, "does not execute cmd.exe to run given command (no output)")
|
|
sessionId = flag.Int("session-id", -1, "Session ID to run the task in (requires SYSTEM privileges)")
|
|
)
|
|
|
|
// Task XML template matching Impacket's exact format
|
|
const taskXMLTemplate = `<?xml version="1.0" encoding="UTF-16"?>
|
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
<Triggers>
|
|
<CalendarTrigger>
|
|
<StartBoundary>2015-07-15T20:35:13.2757294</StartBoundary>
|
|
<Enabled>true</Enabled>
|
|
<ScheduleByDay>
|
|
<DaysInterval>1</DaysInterval>
|
|
</ScheduleByDay>
|
|
</CalendarTrigger>
|
|
</Triggers>
|
|
<Principals>
|
|
<Principal id="LocalSystem">
|
|
<UserId>S-1-5-18</UserId>
|
|
<RunLevel>HighestAvailable</RunLevel>
|
|
</Principal>
|
|
</Principals>
|
|
<Settings>
|
|
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
<AllowHardTerminate>true</AllowHardTerminate>
|
|
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
<IdleSettings>
|
|
<StopOnIdleEnd>true</StopOnIdleEnd>
|
|
<RestartOnIdle>false</RestartOnIdle>
|
|
</IdleSettings>
|
|
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
<Enabled>true</Enabled>
|
|
<Hidden>true</Hidden>
|
|
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
<WakeToRun>false</WakeToRun>
|
|
<ExecutionTimeLimit>P3D</ExecutionTimeLimit>
|
|
<Priority>7</Priority>
|
|
</Settings>
|
|
<Actions Context="LocalSystem">
|
|
<Exec>
|
|
<Command>%s</Command>
|
|
<Arguments>%s</Arguments>
|
|
</Exec>
|
|
</Actions>
|
|
</Task>`
|
|
|
|
func main() {
|
|
opts := flags.Parse()
|
|
|
|
if opts.TargetStr == "" {
|
|
flag.Usage()
|
|
os.Exit(1)
|
|
}
|
|
|
|
target, creds, err := session.ParseTargetString(opts.TargetStr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Error parsing target string: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
opts.ApplyToSession(&target, &creds)
|
|
|
|
// Set target.IP for Kerberos when -dc-ip is specified
|
|
if creds.DCIP != "" {
|
|
target.IP = creds.DCIP
|
|
}
|
|
|
|
if !opts.NoPass {
|
|
if err := session.EnsurePassword(&creds); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Connect via SMB
|
|
smbClient := smb.NewClient(target, &creds)
|
|
if err := smbClient.Connect(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] SMB connection failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer smbClient.Close()
|
|
|
|
// Open atsvc pipe (Task Scheduler)
|
|
atPipe, err := smbClient.OpenPipe("atsvc")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Failed to open atsvc pipe: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
defer atPipe.Close()
|
|
|
|
// Bind ITaskSchedulerService with authentication
|
|
rpcClient := dcerpc.NewClient(atPipe)
|
|
if creds.UseKerberos {
|
|
if err := rpcClient.BindAuthKerberos(tsch.UUID, tsch.MajorVersion, tsch.MinorVersion, &creds, target.Host); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Failed to bind ITaskSchedulerService (Kerberos): %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
if err := rpcClient.BindAuth(tsch.UUID, tsch.MajorVersion, tsch.MinorVersion, &creds); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Failed to bind ITaskSchedulerService: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
ts := tsch.NewTaskScheduler(rpcClient)
|
|
|
|
// Mount ADMIN$ for output retrieval
|
|
if !*noOutput {
|
|
if err := smbClient.UseShare("ADMIN$"); err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Failed to access ADMIN$ share: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Create executor
|
|
executor := &AtExec{
|
|
ts: ts,
|
|
smbClient: smbClient,
|
|
noOutput: *noOutput,
|
|
timeout: *timeout,
|
|
silent: *silentCommand,
|
|
sessionId: *sessionId,
|
|
}
|
|
|
|
// Get command
|
|
command := opts.Command()
|
|
if command == "" {
|
|
executor.interactiveShell()
|
|
} else {
|
|
output, err := executor.execute(command)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Execution failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Print(output)
|
|
}
|
|
}
|
|
|
|
// AtExec handles remote command execution via Task Scheduler
|
|
type AtExec struct {
|
|
ts *tsch.TaskScheduler
|
|
smbClient *smb.Client
|
|
noOutput bool
|
|
timeout int
|
|
silent bool
|
|
sessionId int
|
|
}
|
|
|
|
func (e *AtExec) interactiveShell() {
|
|
fmt.Println("[!] Launching semi-interactive shell - Careful what you execute")
|
|
fmt.Println("[!] Press Ctrl+D or type 'exit' to quit")
|
|
fmt.Println("[!] Type '!command' to run local commands")
|
|
|
|
prompt := "C:\\Windows\\system32>"
|
|
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
for {
|
|
fmt.Print(prompt)
|
|
if !scanner.Scan() {
|
|
fmt.Println()
|
|
break
|
|
}
|
|
cmd := strings.TrimSpace(scanner.Text())
|
|
if cmd == "" {
|
|
continue
|
|
}
|
|
if strings.EqualFold(cmd, "exit") {
|
|
break
|
|
}
|
|
|
|
// Local shell escape
|
|
if strings.HasPrefix(cmd, "!") {
|
|
localCmd := strings.TrimPrefix(cmd, "!")
|
|
if localCmd == "" {
|
|
fmt.Println("[!] Usage: !command - runs command on local system")
|
|
continue
|
|
}
|
|
out, err := exec.Command("sh", "-c", localCmd).CombinedOutput()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Local command error: %v\n", err)
|
|
}
|
|
fmt.Print(string(out))
|
|
continue
|
|
}
|
|
|
|
output, err := e.execute(cmd)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "[-] Error: %v\n", err)
|
|
continue
|
|
}
|
|
fmt.Print(output)
|
|
}
|
|
}
|
|
|
|
func (e *AtExec) execute(command string) (string, error) {
|
|
// Generate random task name and output file name
|
|
taskName := generateRandomString(8)
|
|
tmpFileName := generateRandomString(8) + ".tmp"
|
|
|
|
// Build command and arguments like Impacket does
|
|
var cmd, args string
|
|
if e.silent {
|
|
// Direct command: split on first space
|
|
parts := strings.SplitN(command, " ", 2)
|
|
cmd = parts[0]
|
|
if len(parts) > 1 {
|
|
args = parts[1]
|
|
}
|
|
} else {
|
|
// Standard execution: cmd.exe /C command > output
|
|
cmd = "cmd.exe"
|
|
if e.noOutput {
|
|
args = fmt.Sprintf("/C %s", command)
|
|
} else {
|
|
args = fmt.Sprintf("/C %s > %%windir%%\\Temp\\%s 2>&1", command, tmpFileName)
|
|
}
|
|
}
|
|
|
|
// Build task XML with escaped command and arguments
|
|
taskXML := fmt.Sprintf(taskXMLTemplate, xmlEscape(cmd), xmlEscape(args))
|
|
|
|
taskPath := "\\" + taskName
|
|
|
|
// Register the task
|
|
_, err := e.ts.RegisterTask(taskPath, taskXML, tsch.TASK_CREATE)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to register task: %v", err)
|
|
}
|
|
|
|
// Run the task immediately
|
|
if e.sessionId >= 0 {
|
|
err = e.ts.RunWithSessionId(taskPath, uint32(e.sessionId))
|
|
} else {
|
|
err = e.ts.Run(taskPath)
|
|
}
|
|
if err != nil {
|
|
// Still try to clean up the task
|
|
e.ts.Delete(taskPath)
|
|
return "", fmt.Errorf("failed to run task: %v", err)
|
|
}
|
|
|
|
// Retrieve output or wait for completion
|
|
var output string
|
|
if !e.noOutput {
|
|
output = e.getOutput(tmpFileName)
|
|
} else {
|
|
// When -nooutput is used, wait for task completion using GetLastRunInfo
|
|
e.waitForTaskCompletion(taskPath)
|
|
}
|
|
|
|
// Delete the task
|
|
e.ts.Delete(taskPath)
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// waitForTaskCompletion polls GetLastRunInfo until the task has run.
|
|
func (e *AtExec) waitForTaskCompletion(taskPath string) {
|
|
maxIterations := e.timeout * 10 // 100ms intervals
|
|
for i := 0; i < maxIterations; i++ {
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
st, _, err := e.ts.GetLastRunInfo(taskPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Task has run if LastRunTime is set (Year != 0)
|
|
if st.HasRun() {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *AtExec) getOutput(tmpFileName string) string {
|
|
// The output file is at Windows\Temp\<filename> relative to ADMIN$ share
|
|
outputPath := "Temp\\" + tmpFileName
|
|
|
|
// Poll for output file with timeout
|
|
maxIterations := e.timeout * 10 // 100ms intervals
|
|
for i := 0; i < maxIterations; i++ {
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
content, err := e.smbClient.Cat(outputPath)
|
|
if err == nil {
|
|
// Cat succeeds even while the remote process is still writing;
|
|
// Rm is what fails with a sharing violation in that case.
|
|
if rmErr := e.smbClient.Rm(outputPath); rmErr != nil && smb.IsSharingViolation(rmErr) {
|
|
continue
|
|
}
|
|
return content
|
|
}
|
|
|
|
// File not yet created — keep waiting.
|
|
if smb.IsNotFound(err) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// xmlEscape escapes special XML characters to match Impacket's xml_escape
|
|
func xmlEscape(s string) string {
|
|
s = strings.ReplaceAll(s, "&", "&")
|
|
s = strings.ReplaceAll(s, "\"", """)
|
|
s = strings.ReplaceAll(s, "'", "'")
|
|
s = strings.ReplaceAll(s, ">", ">")
|
|
s = strings.ReplaceAll(s, "<", "<")
|
|
return s
|
|
}
|
|
|
|
// generateRandomString generates a random alphanumeric string
|
|
func generateRandomString(length int) string {
|
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
b := make([]byte, length)
|
|
rand.Read(b)
|
|
for i := range b {
|
|
b[i] = chars[int(b[i])%len(chars)]
|
|
}
|
|
return string(b)
|
|
}
|