smbclient: add list_snapshots command

Fixes #8. smbclient's interactive shell now supports list_snapshots,
which enumerates VSS shadow copies on the currently selected share
via FSCTL_SRV_ENUMERATE_SNAPSHOTS. Matches Impacket smbclient.py's
list_snapshots output format.

The response follows MS-SMB2 2.2.32 and requires a two-call size
probe: the first ioctl uses a 16-byte output buffer to read the
SRV_SNAPSHOT_ARRAY header (SnapShotArraySize tells us how large the
second buffer needs to be), then a second ioctl asks for the full
payload and parses the UTF-16LE NUL-separated @GMT-... tokens.

Changes:
- pkg/third_party/smb2/client.go: add an exported Ioctl method on
  *File as a thin wrapper over the internal ioctl helper, so FSCTL
  operations beyond FSCTL_PIPE_TRANSCEIVE don't need their own
  dedicated method in the vendored library.
- pkg/smb/client.go: EnumerateSnapshots() method on *Client. Handles
  the size-probe dance, parses the UTF-16LE token list via
  pkg/utf16le, returns []string. Empty slice when no snapshots exist.
- tools/smbclient/main.go: wire list_snapshots into the shell's
  command switch and help text.

Unblocks the VSS-based NTDS extraction path:
  use c$
  list_snapshots      # get @GMT-YYYY.MM.DD-HH.MM.SS
  get @GMT-...\Windows\NTDS\ntds.dit
  secretsdump -ntds ntds.dit -system SYSTEM
This commit is contained in:
psycep
2026-04-22 13:46:16 -05:00
parent a0afb8d5b9
commit 100500df41
3 changed files with 110 additions and 0 deletions
+75
View File
@@ -15,6 +15,7 @@
package smb
import (
"encoding/binary"
"encoding/hex"
"fmt"
"io"
@@ -31,6 +32,7 @@ import (
"github.com/mandiant/gopacket/pkg/session"
"github.com/mandiant/gopacket/pkg/third_party/smb2"
"github.com/mandiant/gopacket/pkg/transport"
"github.com/mandiant/gopacket/pkg/utf16le"
)
type Client struct {
@@ -408,3 +410,76 @@ func (c *Client) Mget(pattern string) error {
}
return nil
}
// fsctlSrvEnumerateSnapshots is FSCTL_SRV_ENUMERATE_SNAPSHOTS per MS-FSCC
// 2.3.23 / MS-SMB2 2.2.31. Enumerates VSS shadow copies on the share.
const fsctlSrvEnumerateSnapshots = 0x00144064
// EnumerateSnapshots returns the VSS shadow-copy tokens available on the
// currently selected share, formatted as `@GMT-YYYY.MM.DD-HH.MM.SS`. Requires
// a share to be selected via UseShare. Returns an empty slice if no snapshots
// exist. Implements the two-call size-probe pattern from MS-SMB2 2.2.32.
func (c *Client) EnumerateSnapshots() ([]string, error) {
if c.currentShare == nil {
return nil, fmt.Errorf("no share selected")
}
f, err := c.currentShare.Open(".")
if err != nil {
return nil, fmt.Errorf("open share root: %v", err)
}
defer f.Close()
// First call with just enough buffer for the 12-byte SRV_SNAPSHOT_ARRAY
// header. The server returns SnapShotArraySize so we know how large the
// second call's buffer needs to be.
hdr, err := f.Ioctl(fsctlSrvEnumerateSnapshots, nil, 16)
if err != nil {
return nil, fmt.Errorf("ioctl (probe): %v", err)
}
if len(hdr) < 12 {
return nil, fmt.Errorf("snapshot probe response too short: %d bytes", len(hdr))
}
arraySize := binary.LittleEndian.Uint32(hdr[8:12])
if arraySize == 0 {
return []string{}, nil
}
// Second call, request header + full payload.
resp, err := f.Ioctl(fsctlSrvEnumerateSnapshots, nil, 12+int(arraySize))
if err != nil {
return nil, fmt.Errorf("ioctl (full): %v", err)
}
if len(resp) < 12 {
return nil, fmt.Errorf("snapshot response too short: %d bytes", len(resp))
}
numReturned := binary.LittleEndian.Uint32(resp[4:8])
arraySize = binary.LittleEndian.Uint32(resp[8:12])
if uint32(len(resp)) < 12+arraySize {
return nil, fmt.Errorf("snapshot payload truncated: got %d, want %d", len(resp), 12+arraySize)
}
data := resp[12 : 12+arraySize]
// The payload is a sequence of UTF-16LE NUL-terminated strings followed by
// a final UTF-16LE NUL terminator. Walk two bytes at a time looking for
// UTF-16 NULs.
var out []string
for i := 0; i+1 < len(data); {
end := i
for end+1 < len(data) {
if data[end] == 0 && data[end+1] == 0 {
break
}
end += 2
}
if end == i {
break // list terminator
}
out = append(out, utf16le.DecodeToString(data[i:end]))
i = end + 2
if uint32(len(out)) >= numReturned {
break
}
}
return out, nil
}
+21
View File
@@ -2061,6 +2061,27 @@ func (f *File) Transact(input []byte, maxOutput int) ([]byte, error) {
return f.ioctl(req)
}
// Ioctl sends an arbitrary FSCTL against the open file handle. Thin public
// wrapper over the internal ioctl method, used for operations that don't have
// a dedicated helper (e.g. FSCTL_SRV_ENUMERATE_SNAPSHOTS). The input slice may
// be nil for control codes that take no payload.
func (f *File) Ioctl(ctlCode uint32, input []byte, maxOutput int) ([]byte, error) {
f.m.Lock()
defer f.m.Unlock()
req := &IoctlRequest{
CtlCode: ctlCode,
OutputOffset: 0,
OutputCount: 0,
MaxInputResponse: 0,
MaxOutputResponse: uint32(maxOutput),
Flags: SMB2_0_IOCTL_IS_FSCTL,
Input: RawBytes(input),
}
return f.ioctl(req)
}
func (f *File) readdir(pattern string) (fi []os.FileInfo, err error) {
req := &QueryDirectoryRequest{
FileInfoClass: FileDirectoryInformation,
+14
View File
@@ -349,6 +349,19 @@ func shell(client *smb.Client, hostname string, inputFile string) {
fmt.Printf("[+] %s\n", inf)
}
p.Close()
case "list_snapshots":
snaps, err := client.EnumerateSnapshots()
if err != nil {
fmt.Printf("[-] Error: %v\n", err)
continue
}
if len(snaps) == 0 {
fmt.Println("No snapshots available on this share.")
continue
}
for _, s := range snaps {
fmt.Println(s)
}
case "help":
fmt.Print(`
logoff - logs off
@@ -368,6 +381,7 @@ func shell(client *smb.Client, hostname string, inputFile string) {
mget {mask} - downloads all files from the current directory matching the provided mask
cat {filename} - reads the filename from the current path
info - returns NetrServerInfo main results
list_snapshots - lists VSS shadow copies available on the current share
close - closes the current SMB Session
exit - terminates the session
`)