src/programFrames/exe21sp.cs

// Uses AsmResolver to read embedded script resources from a ps12exe-built exe
// and return the original PowerShell script text. Exposed via the exe21sp PowerShell helper.
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Text;
using AsmResolver.DotNet;
using AsmResolver.PE.File;
 
namespace exe21sp {
    /// <summary>
    /// 当前宿主没有 BrotliStream(.NET Framework 不提供),需要转交 pwsh / .NET Core 解压。
    /// </summary>
    public sealed class BrotliUnavailableException : Exception { }
 
    public static class Extractor {
        /// <summary>
        /// Extracts the embedded PowerShell script from a ps12exe-built executable.
        /// </summary>
        /// <param name="exePath">Full path to the .exe file.</param>
        /// <returns>
        /// For normal ps12exe exes: the original PowerShell script from an embedded resource.
        /// For TinySharp-compiled exes: a synthesized script that prints the captured output string and,
        /// if applicable, appends an exit statement with the recorded exit code.
        /// Returns null if the exe is not a ps12exe output or payload cannot be recovered.
        /// </returns>
        public static string ExtractScriptFromExe(string exePath) {
            if (string.IsNullOrEmpty(exePath) || !File.Exists(exePath))
                return null;
            // First, try the standard program frame: embedded main.ps1 resource.
            var script = TryExtractFromFrame(exePath);
            if (script != null)
                return script;
 
            // Fallback: TinySharp-compiled minimal exe (no script resource).
            return TryExtractFromTinySharp(exePath);
        }
 
        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;
 
                    // Non-const exes wrap the real assembly in the launcher's "main" resource.
                    // Unwrap it and look for the main.ps1 script resource inside that payload.
                    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))
                    // Detect encoding from BOM when present; default to UTF-8 without BOM.
                    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);
        }
 
        private static string TryExtractFromTinySharp(string exePath) {
            var peFile = PEFile.FromFile(exePath);
            // Only treat as TinySharp when the PE is a .NET assembly (has CLR header).
            // Otherwise native exes (e.g. notepad.exe) would yield garbage from .text.
            if (peFile.OptionalHeader == null)
                return null;
            var clrDir = peFile.OptionalHeader.GetDataDirectory(DataDirectoryIndex.ClrDirectory);
            if (clrDir.Size == 0 || !clrDir.IsPresentInPE)
                return null;
 
            // From here on we consider this a potential TinySharp exe; layout failures must throw.
            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");
 
            // Locate the message string by counting ldc.i4 VA references in the CIL region.
            // TinySharp patches the message address into every MessageBoxW call site (2× for the
            // two-path MessageBox build, 1× for console builds), while infrastructure strings
            // (e.g. VerQueryValueW subBlock path) are referenced only once. The most-referenced
            // VA that maps to actual file content in .text is therefore the message — no content
            // heuristics needed.
            string message = FindMessageByVARefCount(raw, peFile.OptionalHeader.ImageBase, section);
            if (string.IsNullOrEmpty(message))
                throw new InvalidOperationException("TinySharpPayloadNotRecovered");
 
            // TinySharp embeds non-zero exit code as CIL: Ldc_I4 (0x20) + 4-byte LE + Ret (0x2A). Find last such sequence.
            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>
        /// Scans .text for TinySharp main's trailing CIL: Ldc_I4 (0x20) + 4-byte LE exit code + Ret (0x2A).
        /// Only scans the first 2KB (CIL region); string data at end of .text could otherwise false-match.
        /// Returns the last matching exit code, or 0 if not found / not plausible.
        /// </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>
        /// Scans the first 2 KB of .text (the CIL region) for ldc.i4 operands whose value
        /// is a VA within the physical file content of .text. Counts how many times each
        /// such VA appears; the most-referenced one is the message string (TinySharp MessageBox
        /// patches it at every call site — 2×, whereas infra strings like the VerQueryValueW
        /// subBlock path appear only 1×). No content heuristics are used.
        /// </summary>
        private static string FindMessageByVARefCount(byte[] raw, ulong imageBase, PESection section) {
            ulong textVABase = imageBase + section.Rva;
            // Parallel arrays instead of Dictionary<> to avoid requiring extra assembly references.
            // At most a handful of distinct .text VAs appear as ldc.i4 operands in 2 KB of CIL.
            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 opcode
                uint operand = (uint)BitConverter.ToInt32(raw, i + 1);
                // TinySharp imageBase < 2^32, so the ldc.i4 operand IS the full 32-bit VA.
                ulong va = (imageBase & 0xFFFFFFFF00000000UL) | (ulong)operand;
                if (va < textVABase) continue;
                ulong fileOff = va - textVABase;
                if (fileOff >= (ulong)raw.Length) continue; // BSS/virtual — no file content
                // Linear search is fine; < 20 distinct candidates expected.
                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]++;
            }
            // Most-referenced VA = message; tiebreak by lowest file offset (message placed first).
            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;
            // Distinguish encoding by checking whether the second byte is a null (UTF-16LE pattern).
            // MessageBox / WriteConsoleW builds use Unicode (raw[off+1] == 0x00 for ASCII-range text).
            // puts builds use plain ASCII (raw[off+1] is a printable byte, not zero).
            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;
        }
    }
}