src/programFrames/exe21sp.cs
|
// 使用 AsmResolver 读取 ps12exe 生成的 exe 中内嵌的脚本资源,并返回原始 PowerShell 脚本文本。通过 exe21sp PowerShell 辅助程序对外暴露。
using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Text; using AsmResolver; using AsmResolver.DotNet; using AsmResolver.PE; using AsmResolver.PE.File; using AsmResolver.PE.Win32Resources; namespace exe21sp { /// <summary> /// 当前宿主没有 BrotliStream(.NET Framework 不提供),需要转交 pwsh / .NET Core 解压。 /// </summary> public sealed class BrotliUnavailableException : Exception { } public static class Extractor { /// <summary> /// 从 ps12exe 生成的 exe 中提取内嵌的 PowerShell 脚本。 /// </summary> /// <param name="exePath">.exe 文件的完整路径。</param> /// <returns> /// 普通 ps12exe exe:来自内嵌资源的原始 PowerShell 脚本。TinySharp 编译的 exe:合成脚本,它打印捕获的输出字符串,并在适用时追加带有所记录退出代码的 exit 语句。若该 exe 不是 ps12exe 输出或负载无法恢复,则返回 null。 /// </returns> public static string ExtractScriptFromExe(string exePath) { if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) return null; // 首先尝试标准程序框架:内嵌的 main.ps1 资源。 var script = TryExtractFromFrame(exePath); if (script != null) return script; // 回退:TinySharp 编译的最小 exe(无脚本资源)。 return TryExtractFromTinySharp(exePath); } /// <summary> /// 判断 ps12exe 产物是否为 windowed(无控制台)构建,供反编译时补回 <c>#_pragma App.Windowed</c>。 /// 标准产物(CodeDom/Core/pack)直接看最外层 PE 子系统;TinySharp 常量 GUI 产物仍标为控制台子系统, /// 但会 P/Invoke user32!MessageBoxW,额外识别这种情况。无法判断时返回 false。 /// </summary> /// <param name="exePath">.exe 文件的完整路径。</param> public static bool IsWindowedExe(string exePath) { if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) return false; try { var peFile = PEFile.FromFile(exePath); if (peFile.OptionalHeader != null && peFile.OptionalHeader.SubSystem == SubSystem.WindowsGui) return true; } catch { return false; } // 控制台子系统:带内嵌 .ps1 资源的是标准控制台产物(脚本自身可能 P/Invoke MessageBoxW,不能据此误判);否则按 TinySharp 常量产物处理。 try { if (HasEmbeddedScriptResource(exePath)) return false; return TinySharpUsesMessageBox(exePath); } catch { return false; } } // 标准程序框架(default.cs / pack.cs)会内嵌脚本:未压缩是 main.ps1,压缩后是 launcher 的 "main" 负载。据此把标准产物与 TinySharp 常量产物区分开。 private static bool HasEmbeddedScriptResource(string exePath) { var module = ModuleDefinition.FromFile(exePath); foreach (var resource in module.Resources) { if (!resource.IsEmbedded) continue; string name = object.ReferenceEquals(resource.Name, null) ? null : resource.Name.ToString(); if (name != null && (name.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "main", StringComparison.OrdinalIgnoreCase))) return true; } return false; } /// <summary> /// TinySharp 常量 GUI 产物把 user32!MessageBoxW 声明为元数据方法 #1,控制台常量产物则用 puts/WriteConsoleW。方法表没有 TypeDef,AsmResolver 无法枚举 P/Invoke,故直接在 .text 的 CIL 区扫描 call Method#1 的指令字节。 /// </summary> private static bool TinySharpUsesMessageBox(string exePath) { var peFile = PEFile.FromFile(exePath); if (peFile.OptionalHeader == null) return false; var clrDir = peFile.OptionalHeader.GetDataDirectory(DataDirectoryIndex.ClrDirectory); if (clrDir.Size == 0 || !clrDir.IsPresentInPE) return false; PESection section = null; foreach (var s in peFile.Sections) { if (!object.ReferenceEquals(s.Name, null) && s.Name.ToString() == ".text") { section = s; break; } } if (section == null) return false; var size = (uint)Math.Min(section.GetPhysicalSize(), 2048); if (size < 5) return false; var reader = peFile.CreateReaderAtFileOffset(section.Offset, size); var raw = reader.ReadBytes((int)reader.Length); if (raw == null || raw.Length < 5) return false; // call (0x28) + Method 元数据 token #1(0x06000001,小端 01 00 00 06)。 for (int i = 0; i + 4 < raw.Length; i++) { if (raw[i] == 0x28 && raw[i + 1] == 0x01 && raw[i + 2] == 0x00 && raw[i + 3] == 0x00 && raw[i + 4] == 0x06) return true; } return false; } private static string TryExtractFromFrame(string exePath) { // 普通托管 exe 的镜像在偏移 0;Core 的单文件 exe 是原生 apphost 后追加托管负载,因此扫描文件内所有内嵌 PE 镜像,逐个尝试提取。 foreach (var image in EnumerateEmbeddedImages(File.ReadAllBytes(exePath))) { try { var module = ModuleDefinition.FromBytes(image); var script = TryExtractFromModule(module); if (script != null) return script; // 非 const exe 把真正的程序集包在 launcher 的 "main" 资源里。拆开它,并在该负载中寻找 main.ps1 脚本资源。 var payload = TryGetLauncherPayload(module); if (payload != null) return TryExtractFromModule(ModuleDefinition.FromBytes(payload)); } catch (BrotliUnavailableException) { // 需要 .NET Core 才能解压的 Core 负载,交给上层转交 pwsh。 throw; } catch { // 非有效 .NET 模块(如原生 apphost)或读取错误。 } } return null; } /// <summary> /// 逐个产出文件内疑似 PE 镜像的字节切片(从每个 "MZ" 且带有效 PE 头的偏移到文件末尾)。单文件发布的 exe 把托管程序集追加在原生 apphost 之后,需要这样找出来。 /// </summary> private static IEnumerable<byte[]> EnumerateEmbeddedImages(byte[] fileBytes) { for (int offset = 0; offset + 0x40 <= fileBytes.Length; offset++) { if (fileBytes[offset] != 'M' || fileBytes[offset + 1] != 'Z') continue; uint peHeaderOffset = BitConverter.ToUInt32(fileBytes, offset + 0x3C); if (peHeaderOffset < 0x40 || peHeaderOffset > 0x1000) continue; long peOffset = offset + (long)peHeaderOffset; if (peOffset + 4 > fileBytes.Length) continue; if (fileBytes[peOffset] != 'P' || fileBytes[peOffset + 1] != 'E' || fileBytes[peOffset + 2] != 0 || fileBytes[peOffset + 3] != 0) continue; var image = new byte[fileBytes.Length - offset]; Buffer.BlockCopy(fileBytes, offset, image, 0, image.Length); yield return image; } } private static string TryExtractFromModule(ModuleDefinition module) { foreach (var resource in module.Resources) { if (!resource.IsEmbedded) continue; string name = object.ReferenceEquals(resource.Name, null) ? null : resource.Name.ToString(); // 脚本以未压缩的 .ps1 资源内嵌(标准 frame 是 main.ps1)。 if (name != null && name.EndsWith(".ps1", StringComparison.OrdinalIgnoreCase)) { var raw = resource.GetData(); if (raw == null || raw.Length == 0) continue; using (var ms = new MemoryStream(raw)) // 存在 BOM 时据其检测编码;默认使用不带 BOM 的 UTF-8。 using (var reader = new StreamReader(ms, Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) { return reader.ReadToEnd(); } } } return null; } private static byte[] TryGetLauncherPayload(ModuleDefinition module) { foreach (var resource in module.Resources) { if (!resource.IsEmbedded) continue; string name = object.ReferenceEquals(resource.Name, null) ? null : resource.Name.ToString(); if (!string.Equals(name, "main", StringComparison.OrdinalIgnoreCase)) continue; var raw = resource.GetData(); if (raw == null || raw.Length == 0) return null; return DecompressLauncherPayload(raw); } return null; } /// <summary> /// 解压 launcher 的 "main" 负载:Windows PowerShell 构建是 gzip,Core 构建是 Brotli。BrotliStream 不在 .NET Framework 中,故用反射取;不可用时抛 <see cref="BrotliUnavailableException"/>,由 exe21sp 转交 pwsh 处理。 /// </summary> private static byte[] DecompressLauncherPayload(byte[] raw) { using (var ms = new MemoryStream(raw)) { // gzip 流以 1F 8B 开头;否则视为 Brotli(Brotli 无固定魔数)。 Stream decompressor = raw.Length >= 2 && raw[0] == 0x1F && raw[1] == 0x8B ? new GZipStream(ms, CompressionMode.Decompress) : CreateBrotliDecompressor(ms); using (decompressor) using (var outMs = new MemoryStream()) { decompressor.CopyTo(outMs); return outMs.ToArray(); } } } private static Stream CreateBrotliDecompressor(Stream source) { var brotliType = Type.GetType("System.IO.Compression.BrotliStream, System.IO.Compression.Brotli", false); if (brotliType == null) throw new BrotliUnavailableException(); return (Stream)Activator.CreateInstance(brotliType, source, CompressionMode.Decompress); } /// <summary> /// 把 ps12exe 生成的 exe 中内嵌的 Win32 图标重建为独立的 .ico 文件。ps12exe 编译时通过 /win32icon(CodeDom)或 ApplicationIcon(Core)把图标写入最外层 PE,反编译时把它还原出来,供 exe21sp 释放在输出目录并由 #_pragma icon 重新引用。 /// </summary> /// <param name="exePath">.exe 文件的完整路径。</param> /// <returns>.ico 文件字节;当 exe 没有图标资源时返回 null。</returns> public static byte[] ExtractIconFromExe(string exePath) { if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath)) return null; try { return ExtractIconFromImage(PEImage.FromFile(exePath)); } catch { return null; } } private static byte[] ExtractIconFromImage(PEImage image) { var root = image.Resources; if (root == null) return null; ResourceDirectory groupDir; ResourceDirectory iconDir; if (!root.TryGetDirectory(ResourceType.GroupIcon, out groupDir) || groupDir == null) return null; if (!root.TryGetDirectory(ResourceType.Icon, out iconDir) || iconDir == null) return null; // 可能有多个图标组(不同语言/名称),取第一个能完整还原的。 foreach (var groupEntry in groupDir.Entries) { if (!groupEntry.IsDirectory) continue; var groupBytes = ReadFirstEntryBytes((ResourceDirectory)groupEntry); if (groupBytes == null) continue; var ico = BuildIconFile(groupBytes, iconDir); if (ico != null) return ico; } return null; } /// <summary> /// 深度优先读取资源目录下第一份数据。PE 资源树是 类型 → 名称/ID → 语言 → 数据,这里不假设层数,直接找叶子数据。 /// </summary> private static byte[] ReadFirstEntryBytes(ResourceDirectory directory) { foreach (var entry in directory.Entries) { if (entry.IsData) { var data = entry as ResourceData; var bytes = data == null ? null : ReadSegmentBytes(data.Contents); if (bytes != null) return bytes; } else if (entry.IsDirectory) { var bytes = ReadFirstEntryBytes((ResourceDirectory)entry); if (bytes != null) return bytes; } } return null; } private static byte[] ReadSegmentBytes(ISegment segment) { var readable = segment as IReadableSegment; return readable == null ? null : Extensions.ToArray(readable); } /// <summary> /// 按资源 ID 在 RT_ICON 目录里找图标图像数据。目录项里存的 ID 是 16 位。 /// </summary> private static byte[] FindIconImageBytes(ResourceDirectory directory, uint id) { foreach (var entry in directory.Entries) { if (!entry.IsDirectory) continue; if (entry.Id == id) { var bytes = ReadFirstEntryBytes((ResourceDirectory)entry); if (bytes != null) return bytes; } var nested = FindIconImageBytes((ResourceDirectory)entry, id); if (nested != null) return nested; } return null; } /// <summary> /// 把 GRPICONDIR(RT_GROUP_ICON 数据)和对应的 RT_ICON 图像拼成一个标准 .ico 文件。每个目录项 14 字节:宽/高/色数/保留 + 平面数 + 位深 + 数据大小 + 图标 ID。 /// </summary> private static byte[] BuildIconFile(byte[] group, ResourceDirectory iconDir) { if (group == null || group.Length < 6) return null; int type = BitConverter.ToUInt16(group, 2); int count = BitConverter.ToUInt16(group, 4); if (type != 1 || count <= 0 || group.Length < 6 + (count * 14)) return null; var directory = new byte[count][]; var images = new byte[count][]; for (int i = 0; i < count; i++) { int offset = 6 + (i * 14); ushort iconId = BitConverter.ToUInt16(group, offset + 12); var image = FindIconImageBytes(iconDir, iconId); if (image == null) return null; images[i] = image; var entry = new byte[16]; entry[0] = group[offset]; // 宽度 entry[1] = group[offset + 1]; // 高度 entry[2] = group[offset + 2]; // 颜色数 entry[3] = group[offset + 3]; // 保留 Buffer.BlockCopy(group, offset + 4, entry, 4, 2); // 平面数 Buffer.BlockCopy(group, offset + 6, entry, 6, 2); // 位深 Buffer.BlockCopy(BitConverter.GetBytes((uint)image.Length), 0, entry, 8, 4); directory[i] = entry; } using (var output = new MemoryStream()) { output.Write(BitConverter.GetBytes((ushort)0), 0, 2); output.Write(BitConverter.GetBytes((ushort)1), 0, 2); output.Write(BitConverter.GetBytes((ushort)count), 0, 2); uint dataOffset = (uint)(6 + (count * 16)); for (int i = 0; i < count; i++) { Buffer.BlockCopy(BitConverter.GetBytes(dataOffset), 0, directory[i], 12, 4); output.Write(directory[i], 0, directory[i].Length); dataOffset += (uint)images[i].Length; } for (int i = 0; i < count; i++) output.Write(images[i], 0, images[i].Length); return output.ToArray(); } } private static string TryExtractFromTinySharp(string exePath) { var peFile = PEFile.FromFile(exePath); // 仅当该 PE 是 .NET 程序集(含 CLR 头)时才视作 TinySharp。否则原生 exe(如 notepad.exe)会从 .text 中读出垃圾数据。 if (peFile.OptionalHeader == null) return null; var clrDir = peFile.OptionalHeader.GetDataDirectory(DataDirectoryIndex.ClrDirectory); if (clrDir.Size == 0 || !clrDir.IsPresentInPE) return null; // 从这里开始我们将其视为潜在的 TinySharp exe;布局解析失败必须抛出异常。 PESection section = null; foreach (var s in peFile.Sections) { if (!object.ReferenceEquals(s.Name, null) && s.Name.ToString() == ".text") { section = s; break; } } if (section == null) throw new InvalidOperationException("TinySharpNoTextSection"); var size = (uint)Math.Min(section.GetPhysicalSize(), 1024 * 1024); if (size == 0) throw new InvalidOperationException("TinySharpTextSectionEmpty"); var sectionReader = peFile.CreateReaderAtFileOffset(section.Offset, size); var raw = sectionReader.ReadBytes((int)sectionReader.Length); if (raw == null || raw.Length == 0) throw new InvalidOperationException("TinySharpCannotReadText"); // 通过统计 CIL 区域中 ldc.i4 的 VA 引用次数来定位消息字符串。TinySharp 把消息地址打入每个 MessageBoxW 调用点(双路径 MessageBox 构建为 2×,控制台构建为 1×),而基础设施字符串(如 VerQueryValueW 的 subBlock 路径)只被引用一次。因此,映射到 .text 中实际文件内容且引用次数最多的 VA 就是消息——无需内容启发式。 string message = FindMessageByVARefCount(raw, peFile.OptionalHeader.ImageBase, section); if (string.IsNullOrEmpty(message)) throw new InvalidOperationException("TinySharpPayloadNotRecovered"); // TinySharp 把非零退出代码内嵌为 CIL:Ldc_I4 (0x20) + 4 字节 LE + Ret (0x2A)。查找最后一处这样的序列。 int exitCode = TryDetectTinySharpExitCode(raw); var builder = new StringBuilder(); var escaped = message.Replace("'", "''"); builder.Append('\'').Append(escaped).Append('\''); if (exitCode != 0) builder.Append("\nexit ").Append(exitCode); return builder.ToString(); } /// <summary> /// 在 .text 中扫描 TinySharp main 末尾的 CIL:Ldc_I4 (0x20) + 4 字节 LE 退出代码 + Ret (0x2A)。只扫描前 2KB(CIL 区域);否则 .text 末尾的字符串数据可能产生误匹配。返回最后一处匹配的退出代码,若未找到或不可信则返回 0。 /// </summary> private static int TryDetectTinySharpExitCode(byte[] raw) { const byte CilLdcI4 = 0x20; const byte CilRet = 0x2A; const int MinPlausible = -32768; const int MaxPlausible = 32767; int scanLen = Math.Min(raw.Length - 6, 2048); if (scanLen < 0) return 0; int lastExit = 0; for (int i = 0; i <= scanLen; i++) { if (raw[i] != CilLdcI4 || raw[i + 5] != CilRet) continue; int code = BitConverter.ToInt32(raw, i + 1); if (code >= MinPlausible && code <= MaxPlausible) lastExit = code; } return lastExit; } private static bool IsPrintableAscii(string s) { foreach (var c in s) if (c < 32 || c > 126) return false; return true; } private static bool IsPrintableUnicode(string s) { foreach (var c in s) if (char.IsControl(c) && c != '\r' && c != '\n' && c != '\t') return false; return true; } /// <summary> /// 在 .text 的前 2 KB(CIL 区域)中扫描那些取值位于 .text 物理文件内容内的 VA 的 ldc.i4 操作数。统计每个此类 VA 出现的次数;引用最多者即为消息字符串(TinySharp 在每个 MessageBox 调用点都打入它——2×,而 VerQueryValueW 的 subBlock 路径等基础设施字符串只出现 1×)。不使用内容启发式。 /// </summary> private static string FindMessageByVARefCount(byte[] raw, ulong imageBase, PESection section) { ulong textVABase = imageBase + section.Rva; // 使用并行数组而非 Dictionary<>,以避免引入额外的程序集引用。2 KB 的 CIL 中最多出现少数几个不同的 .text VA 作为 ldc.i4 操作数。 const int MaxSlots = 64; uint[] vaKeys = new uint[MaxSlots]; int[] vaCounts = new int[MaxSlots]; int slotCount = 0; int cilEnd = Math.Min(raw.Length - 6, 2048); for (int i = 0; i <= cilEnd; i++) { if (raw[i] != 0x20) continue; // ldc.i4 操作码 uint operand = (uint)BitConverter.ToInt32(raw, i + 1); // TinySharp 的 imageBase < 2^32,因此 ldc.i4 操作数就是完整的 32 位 VA。 ulong va = (imageBase & 0xFFFFFFFF00000000UL) | (ulong)operand; if (va < textVABase) continue; ulong fileOff = va - textVABase; if (fileOff >= (ulong)raw.Length) continue; // BSS/虚拟内存——无文件内容 // 线性查找即可;预计不同候选少于 20 个。 int idx = -1; for (int j = 0; j < slotCount; j++) if (vaKeys[j] == operand) { idx = j; break; } if (idx < 0 && slotCount < MaxSlots) { vaKeys[slotCount] = operand; vaCounts[slotCount] = 1; slotCount++; } else if (idx >= 0) vaCounts[idx]++; } // 引用最多的 VA 即消息;相同则取文件偏移最小者(消息被放在最前)。 int bestCount = 0; ulong bestFileOff = ulong.MaxValue; uint bestOperand = 0; for (int j = 0; j < slotCount; j++) { ulong va = (imageBase & 0xFFFFFFFF00000000UL) | (ulong)vaKeys[j]; ulong fileOff = va - textVABase; if (vaCounts[j] > bestCount || (vaCounts[j] == bestCount && fileOff < bestFileOff)) { bestCount = vaCounts[j]; bestFileOff = fileOff; bestOperand = vaKeys[j]; } } if (bestOperand == 0) return null; int off = (int)bestFileOff; // 通过检查第二个字节是否为 null(UTF-16LE 特征)来区分编码。MessageBox / WriteConsoleW 构建使用 Unicode(对 ASCII 范围内文本有 raw[off+1] == 0x00)。puts 构建使用纯 ASCII(raw[off+1] 是可打印字节,而非零)。 bool looksUtf16 = (off + 1 < raw.Length && raw[off + 1] == 0); if (looksUtf16) { var msgU = TryReadNullTermUnicode(raw, off); return msgU ?? TryReadNullTermAscii(raw, off); } else { var msgA = TryReadNullTermAscii(raw, off); return msgA ?? TryReadNullTermUnicode(raw, off); } } private static string TryReadNullTermUnicode(byte[] raw, int offset) { if (offset < 0 || offset + 2 > raw.Length) return null; int end = offset; while (end + 1 < raw.Length && (raw[end] != 0 || raw[end + 1] != 0)) end += 2; if (end == offset || end - offset > 8192) return null; var s = Encoding.Unicode.GetString(raw, offset, end - offset); return IsPrintableUnicode(s) ? s : null; } private static string TryReadNullTermAscii(byte[] raw, int offset) { if (offset < 0 || offset >= raw.Length) return null; int end = offset; while (end < raw.Length && raw[end] != 0) end++; if (end == offset || end - offset > 8192) return null; var s = Encoding.ASCII.GetString(raw, offset, end - offset); return IsPrintableAscii(s) ? s : null; } } } |