feat(assets): brouiller les blocs embarques dans l'exe

Le frontend etait lisible dans l'onglet Image de Process Explorer, avec
les commentaires. BuildAssets.ps1 brouille chaque fichier avant brcc32,
PM.EmbeddedAssets le debrouille au moment de servir.

C'est de l'opacite, pas du secret : la cle est dans l'exe. Ca supprime
strings et l'onglet Image ; la memoire, un debogueur et la reponse HTTP
rendent toujours le texte. D'ou XOR plutot qu'AES — a securite egale
(nulle contre qui a l'exe), dix lignes, et aucune force affichee qu'on
n'a pas.

Une magie PMA1 prefixe le clair : sans elle, deux constantes qui derivent
serviraient du binaire au navigateur, ecran blanc sans trace.

Verifie en comparant les flux des deux cotes bloc par bloc, puis par
aller-retour sur app.js, index.html et style.css. Le frontend ne se
trouve plus dans le binaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-09-05 17:17:15 +01:00
parent 8f0bbb580f
commit 427d294b22
4 changed files with 195 additions and 13 deletions
+99 -12
View File
@@ -36,7 +36,78 @@ function TryServeEmbedded(ARequest: TIdHTTPRequestInfo;
implementation
uses
Winapi.Windows;
Winapi.Windows, System.Hash;
// ===== Brouillage des blocs embarques ========================================
//
// CE N'EST PAS DU SECRET, C'EST DE L'OPACITE, et il faut le dire pour ne pas
// s'en croire protege. La cle est forcement dans l'exe, a quelques centaines
// d'octets d'ici : quiconque tient le binaire peut retrouver le texte. Ce que
// ca supprime, c'est l'onglet "Image" de Process Explorer et n'importe quel
// `strings` — le frontend n'est plus lisible en survolant le fichier. L'onglet
// "Memory", un debogueur, ou simplement la reponse HTTP le rendent toujours :
// WebView2 doit recevoir du JavaScript en clair pour l'executer.
//
// D'ou le choix d'un flux XOR plutot que d'AES : a securite rigoureusement
// egale (nulle, contre qui a l'exe), c'est dix lignes au lieu d'un appel CNG,
// et surtout ca n'affiche pas une force qu'on n'a pas.
//
// Le flux depend du NOM DE LA RESSOURCE : deux fichiers qui commencent
// pareil — et ils commencent tous par les memes lignes de commentaire — ne
// produisent pas la meme sortie.
const
// Doit correspondre EXACTEMENT a $ObfSecret dans BuildAssets.ps1.
// Les deux se desaccordent = magie invalide = 404 sur /index.html.
AssetObfSecret: array[0..31] of Byte = (
$9A, $AC, $C6, $05, $DE, $D6, $FA, $E7,
$4F, $95, $04, $E4, $2C, $1D, $DF, $26,
$9B, $BA, $C9, $D2, $0A, $52, $D5, $90,
$B1, $5E, $A8, $09, $12, $9B, $9E, $6C
);
// Prefixe le clair AVANT brouillage. Sans lui, une cle qui a derive entre le
// script et l'exe servirait du binaire au navigateur : ecran blanc, aucune
// trace. Avec lui, on sait refuser et le dire.
AssetMagic = 'PMA1';
// Le XOR est son propre inverse : la meme fonction brouille et debrouille.
// C'est BuildAssets.ps1 qui tient l'autre moitie, et les deux doivent produire
// le meme flux — d'ou le detail du format, ecrit ici une fois pour toutes :
//
// bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
//
function DeobfuscateAsset(const AResName: string; const AData: TBytes): TBytes;
var
LSeed, LName, LBlock: TBytes;
LSha: THashSHA2;
LIdx: Cardinal;
I, J: Integer;
begin
SetLength(Result, Length(AData));
LName := TEncoding.UTF8.GetBytes(AResName);
SetLength(LSeed, 32 + Length(LName));
Move(AssetObfSecret[0], LSeed[0], 32);
if Length(LName) > 0 then
Move(LName[0], LSeed[32], Length(LName));
LIdx := 0;
I := 0;
while I < Length(AData) do
begin
LSha := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256);
LSha.Update(LSeed);
LSha.Update(LIdx, SizeOf(LIdx));
LBlock := LSha.HashAsBytes;
for J := 0 to High(LBlock) do
begin
if I + J >= Length(AData) then Break;
Result[I + J] := AData[I + J] xor LBlock[J];
end;
Inc(I, Length(LBlock));
Inc(LIdx);
end;
end;
// The manifest is auto-generated. A stub is checked in so the project
// compiles before BuildAssets.ps1 ever runs; running the script overwrites
@@ -86,28 +157,44 @@ var
LResName, LExt: string;
LStream: TResourceStream;
LMS: TMemoryStream;
LRaw, LPlain: TBytes;
begin
Result := False;
if not SameText(ARequest.Command, 'GET') then Exit;
if not FindResourceFor(ARequest.Document, LResName) then Exit;
if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit;
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
try
SetLength(LRaw, LStream.Size);
if LStream.Size > 0 then
LStream.ReadBuffer(LRaw[0], LStream.Size);
finally
LStream.Free;
end;
LPlain := DeobfuscateAsset(LResName, LRaw);
// La magie tombe = le script et l'exe n'ont pas la meme cle. Refuser vaut
// mieux que servir du binaire : un 404 se voit dans le journal, un ecran
// blanc ne dit rien. Symptome a reconnaitre : /index.html en 404 juste apres
// avoir touche AssetObfSecret d'un seul cote.
if (Length(LPlain) < Length(AssetMagic)) or
(TEncoding.ANSI.GetString(Copy(LPlain, 0, Length(AssetMagic))) <> AssetMagic) then
Exit(False);
LExt := ExtractFileExt(ARequest.Document);
if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then
LExt := '.html';
AResponse.ContentType := MimeTypeFor(LExt);
LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA);
try
// Copy into a TMemoryStream so Indy can own and free it after the response.
LMS := TMemoryStream.Create;
LMS.CopyFrom(LStream, 0);
LMS.Position := 0;
AResponse.ContentStream := LMS;
AResponse.FreeContentStream := True;
finally
LStream.Free;
end;
// Copy into a TMemoryStream so Indy can own and free it after the response.
LMS := TMemoryStream.Create;
if Length(LPlain) > Length(AssetMagic) then
LMS.WriteBuffer(LPlain[Length(AssetMagic)], Length(LPlain) - Length(AssetMagic));
LMS.Position := 0;
AResponse.ContentStream := LMS;
AResponse.FreeContentStream := True;
AResponse.ResponseNo := 200;
Result := True;
end;
+54 -1
View File
@@ -155,6 +155,59 @@ if ($jsFiles) {
}
}
# --- Brouillage des blocs embarques -------------------------------------------
#
# CE N'EST PAS DU SECRET, C'EST DE L'OPACITE. La cle vit dans l'exe, donc qui
# tient le binaire retrouve le texte. Ce que ca supprime, c'est l'onglet
# "Image" de Process Explorer et n'importe quel `strings` : le frontend n'est
# plus lisible en survolant le fichier. La memoire du processus, un debogueur
# ou la reponse HTTP le rendent toujours - WebView2 doit recevoir du
# JavaScript en clair pour l'executer.
#
# L'AUTRE MOITIE EST DANS PM.EmbeddedAssets.pas ET LES DEUX DOIVENT S'ACCORDER :
#
# bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
# sortie = XOR( magie 'PMA1' + contenu du fichier )
#
# La magie prefixe le CLAIR : elle permet a l'exe de reconnaitre une cle qui a
# derive et de refuser, au lieu de servir du binaire au navigateur (ecran blanc
# sans trace). Symptome d'un desaccord : /index.html en 404.
$ObfSecret = [byte[]]@(
0x9A,0xAC,0xC6,0x05,0xDE,0xD6,0xFA,0xE7,
0x4F,0x95,0x04,0xE4,0x2C,0x1D,0xDF,0x26,
0x9B,0xBA,0xC9,0xD2,0x0A,0x52,0xD5,0x90,
0xB1,0x5E,0xA8,0x09,0x12,0x9B,0x9E,0x6C
)
$ObfMagic = [Text.Encoding]::ASCII.GetBytes('PMA1')
function Obfuscate([byte[]] $plain, [string] $resName) {
$sha = [System.Security.Cryptography.SHA256]::Create()
$seed = $ObfSecret + [Text.Encoding]::UTF8.GetBytes($resName)
$out = New-Object byte[] $plain.Length
$i = 0; $idx = 0
while ($i -lt $plain.Length) {
$block = $sha.ComputeHash($seed + [BitConverter]::GetBytes([uint32]$idx))
for ($j = 0; $j -lt $block.Length -and ($i + $j) -lt $plain.Length; $j++) {
$out[$i + $j] = $plain[$i + $j] -bxor $block[$j]
}
$i += $block.Length; $idx++
}
return $out
}
# Les blocs brouilles vont dans un dossier a part : brcc32 lit ce que le .rc
# lui montre, et on ne veut surtout pas ecraser les sources en clair.
$ObfDir = Join-Path $ScriptDir 'obf'
if (Test-Path $ObfDir) { Remove-Item $ObfDir -Recurse -Force }
New-Item -ItemType Directory -Path $ObfDir | Out-Null
foreach ($f in $files) {
$plain = $ObfMagic + [System.IO.File]::ReadAllBytes($f.FullPath)
$dest = Join-Path $ObfDir ($f.ResName + '.bin')
[System.IO.File]::WriteAllBytes($dest, (Obfuscate $plain $f.ResName))
$f | Add-Member -NotePropertyName ObfPath -NotePropertyValue $dest -Force
}
Log "Brouille $($files.Count) bloc(s) dans $ObfDir"
# --- Generate assets.rc -------------------------------------------------------
$rc = New-Object System.Text.StringBuilder
[void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.')
@@ -162,7 +215,7 @@ $rc = New-Object System.Text.StringBuilder
[void]$rc.AppendLine('')
foreach ($f in $files) {
# brcc32 accepts forward slashes; backslashes need to be doubled in C strings
$rcPath = $f.FullPath -replace '\\','\\'
$rcPath = $f.ObfPath -replace '\\','\\'
[void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"")
}
[System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII)