C# - 自己解凍書庫 - 基本機能

## はじめに

ひと昔前、VC++ で、ZIP解凍処理に zlib を利用して、自己解凍書庫を作成したことがあります。

C# に関する記事を書いているので、C# - Windows Forms - .NET Framework 4.8 を利用した、自己解凍書庫の作り方を説明しようと思います。

今回は基本機能の記載で、続編が [C# - 自己解凍書庫 - 定義ファイル追加](https://qiita.com/chai0917/items/63645f617249b95be2ea) です。

::: note
.NET Framework 4.8 は、現在利用されている Windows にプレインストールされていますが、.NET はプレインストールされていません。
.NET では、ターゲットプラットフォームで自己完結を選択すると、フレームワークを内包できますが、自己解凍書庫のメカニズムに影響がありそうです。
上記、理由により、フレームワーク導入が不要な .NET Framework 4.8 のみ対象とする情報になります。
:::

::: note
解凍処理など時間がかかる可能性のある処理は、非同期処理、ワーキングダイアログ実装などが望ましいですが、本記事では、自己解凍書庫のメカニズムを説明することを目的とするので、ソースコードは同期処理のメソッドで記載します。
:::


### 参考情報

下記情報を参考にさせて頂きました。

- [自己解凍形式（自己解凍書庫 / 自己解凍ファイル）とは - IT用語辞典 e-Words](https://e-words.jp/w/%E8%87%AA%E5%B7%B1%E8%A7%A3%E5%87%8D%E5%BD%A2%E5%BC%8F.html)
- [EXEファイルの内部構造（PEヘッダ）| CodeZine](https://codezine.jp/article/detail/412)
- [PEフォーマットを解釈せよ！：リバースエンジニアリング入門](https://atmarkit.itmedia.co.jp/ait/articles/1202/17/news129.html)

### テスト環境
ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。

- Windows Forms - .NET Framework 4.8

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::


### 素材

Windows Forms アイコンとして下記を利用させて頂きました。

- [フリーの圧縮フォルダアイコン | ICOOON MONO](https://icooon-mono.com/12054-%e3%83%95%e3%83%aa%e3%83%bc%e3%81%ae%e5%9c%a7%e7%b8%ae%e3%83%95%e3%82%a9%e3%83%ab%e3%83%80%e3%82%a2%e3%82%a4%e3%82%b3%e3%83%b3/)
  - rgb（255, 102, 0）

## 自己解凍書庫

自己解凍書庫（自己解凍形式、自己解凍ファイル）は、圧縮・暗号化されたアーカイブとともに、自身を解凍（展開）するためのプログラムを付加した実行可能形式ファイル（EXE）です。
ZIPアーカイブを用いた、自己解凍書庫の基本的なメカニズムを以下に記載します。

![ZIP-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/dcf79509-2e20-6849-e7d1-97a2cda5cd2a.png)

- 「自己解凍書庫ランチャー」「ZIPアーカイブ」の２ファイル、もしくは、「自己解凍書庫ランチャー」「動作定義ファイル」「ZIPアーカイブ」の３ファイルを、単一ファイルに結合
- 「自己解凍書庫ランチャー」では、自身のPEヘッダから自身のサイズを算出
- 「自己解凍書庫ランチャー」「ZIPアーカイブ」の２ファイルの場合、ファイル全体から上記算出サイズ以降を ZIPアーカイブとして抽出して、解凍を実施


自己解凍書庫で、解凍後に、解凍した実行可能形式ファイルを自動起動させると、簡易的なインストーラとして利用できます。

暗号化されたアーカイブは、一般的にウィルス対策ソフトでスキャンできないため、自己解凍書庫がマルウェアとして悪用されることもあります。
信頼できる入手先から入手、もしくは、証明書で発行元が確認できた自己解凍書庫以外は注意が必要です。


## 基本機能

まずは、「自己解凍書庫ランチャー」「ZIPアーカイブ」だけの基本機能を説明します。
- 自己解凍書庫ランチャー：ZipMelt.exe
  - Windows Forms - .NET Framework 4.8
  - 起動すると下記画面を表示、解凍先を指定して「解凍実行」 で解凍実施<br>![ZIP-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/141cf49d-fe71-6266-9e2f-7aa24924be8e.png)
- ZIPアーカイブ：hoge.zip
  - 圧縮ソフト（フリーソフト利用、もしくは、C# などで自作）で作成してください

Windows でファイル結合は「copy /b」で実施できます。
上記２ファイルを結合して Hoge.exe を作成します。

```shell-session
> copy /b ZipMelt.exe + hoge.zip  hoge.exe
```

## サンプルコード
### 前準備

まず、ZIP操作を行うために、プロジェクトの参照に下記２つを追加します。

```
System.IO.Compression
System.IO.Compression.FileSystem
```

### 処理フロー

ダイアログの「解凍実行」Click イベントハンドラで、処理フローを組み立ててみます。

```CSharp
using System.IO;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
```

```CSharp
private void btnMelt_Click(object sender, EventArgs e)
{
  string myself = Assembly.GetExecutingAssembly().Location;  // 自プログラム
  string zipFile = "Piyo.zip";        // ユニークなワークファイルとしてください
  string targetFolder = txtTargetFolder.Text.Trim();         // ダイアログ指定値

  using(var fs = new FileStream(myself, FileMode.Open, FileAccess.Read))
  {
    // PEヘッダからプログラムサイズ算出
    long size = GetSizeFromPeHeader(fs);
    if (size < 0)
    {
      // ERROR - TODO
    }

    // FileStream 位置をプログラム末尾に設定
    fs.Seek(size, SeekOrigin.Begin);

    // FileStream 現在位置からファイル末尾までをZIPファイルに抽出
    MyApp2ZipFile(fs, zipFile);
  }

  // ZIP解凍
  using (var arc = ZipFile.OpenRead(zipFile))
  {
    arc.ExtractToDirectory(targetFolder);
  }

  // 後処理
  if (File.Exists(zipFile))
  {
    File.Delete(zipFile);
  }
}
```

### PEヘッダ

PE（Portable Executable）とは、Windows 上の実行可能ファイル、ライブラリ等の主流フォーマットで、VC++、.NET Framework、.NET などの生成物も含まれます。

PEヘッダの構造は、[EXEファイルの内部構造（PEヘッダ）| CodeZine](https://codezine.jp/article/detail/412) などに詳しい情報があります。
プログラムサイズ算出に必要なメンバーを抽出した図を記載します。

![ZIP-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/6006cd1c-1327-0abc-84d5-8f550ec888af.png)


具体的なプログラムサイズ算出コードを以下に記載します。

```CSharp
// PEヘッダからプログラムサイズ算出
private long GetSizeFromPeHeader(FileStream fs)
{
  // IMAGE_DOS_HEADER
  byte[] bufsImageDosHeader = new byte[Marshal.SizeOf(typeof(IMAGE_DOS_HEADER))];
  IntPtr ptrImageDosHeader = Marshal.AllocHGlobal(bufsImageDosHeader.Length);
  fs.Seek(0, SeekOrigin.Begin);
  fs.Read(bufsImageDosHeader, 0, bufsImageDosHeader.Length);
  Marshal.Copy(bufsImageDosHeader, 0, ptrImageDosHeader, 
               bufsImageDosHeader.Length);
  IMAGE_DOS_HEADER imgDosHeader =
    (IMAGE_DOS_HEADER)Marshal.PtrToStructure(ptrImageDosHeader, 
                                             typeof(IMAGE_DOS_HEADER));
  if (imgDosHeader.e_magic != 0x5A4Du)
  {
    Marshal.FreeHGlobal(ptrImageDosHeader);
    return -1;
  }

  // IMAGE_NT_HEADERS32
  byte[] bufsImageNtHeaders32 = new byte[Marshal.SizeOf(typeof(IMAGE_NT_HEADERS32))];
  IntPtr ptrImageNtHeaders32 = Marshal.AllocHGlobal(bufsImageNtHeaders32.Length);
  fs.Seek(imgDosHeader.e_lfanew, SeekOrigin.Begin);
  fs.Read(bufsImageNtHeaders32, 0, bufsImageNtHeaders32.Length);
  Marshal.Copy(bufsImageNtHeaders32, 0, ptrImageNtHeaders32, 
               bufsImageNtHeaders32.Length);
  IMAGE_NT_HEADERS32 imgNtHeader32 =
    (IMAGE_NT_HEADERS32)Marshal.PtrToStructure(ptrImageNtHeaders32, 
                                               typeof(IMAGE_NT_HEADERS32));
  if (imgNtHeader32.Signature != 0x4550u)
  {
    Marshal.FreeHGlobal(ptrImageDosHeader);
    Marshal.FreeHGlobal(ptrImageNtHeaders32);
    return -1;
  }
  int nSecCount = imgNtHeader32.FileHeader.NumberOfSections;
  int pos = imgDosHeader.e_lfanew + Marshal.SizeOf(typeof(IMAGE_NT_HEADERS32))
            + (Marshal.SizeOf(typeof(IMAGE_SECTION_HEADER)) * (nSecCount - 1));

  // IMAGE_SECTION_HEADER - 最終セクション
  byte[] bufsImageSectionHeader = new byte[Marshal.SizeOf(typeof(IMAGE_SECTION_HEADER))];
  IntPtr ptrImageSectionHeader = Marshal.AllocHGlobal(bufsImageSectionHeader.Length);
  fs.Seek(pos, SeekOrigin.Begin);
  fs.Read(bufsImageSectionHeader, 0, bufsImageSectionHeader.Length);
  Marshal.Copy(bufsImageSectionHeader, 0, ptrImageSectionHeader, 
               bufsImageSectionHeader.Length);
  IMAGE_SECTION_HEADER imgSectionHeader =
    (IMAGE_SECTION_HEADER)Marshal.PtrToStructure(ptrImageSectionHeader,
                                                 typeof(IMAGE_SECTION_HEADER));
  long size = imgSectionHeader.SizeOfRawData + imgSectionHeader.PointerToRawData;

  // 後処理
  Marshal.FreeHGlobal(ptrImageDosHeader);
  Marshal.FreeHGlobal(ptrImageNtHeaders32);
  Marshal.FreeHGlobal(ptrImageSectionHeader);

  return size;
}
```

```CSharp
// ファイル構造
[StructLayout(LayoutKind.Explicit, Size = 64)]
public struct IMAGE_DOS_HEADER
{
  [FieldOffset(0)]
  public UInt16 e_magic;  // Magic number
  [FieldOffset(60)]
  public Int32 e_lfanew;  // File address of new exe header
}

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct IMAGE_FILE_HEADER
{
  public UInt16 Machine;
  public UInt16 NumberOfSections;
  public UInt32 TimeDateStamp;
  public UInt32 PointerToSymbolTable;
  public UInt32 NumberOfSymbols;
  public UInt16 SizeOfOptionalHeader;
  public UInt16 Characteristics;
}

[StructLayout(LayoutKind.Explicit, Size = 248)]
public struct IMAGE_NT_HEADERS32
{
  [FieldOffset(0)]
  public UInt32 Signature;
  [FieldOffset(4)]
  public IMAGE_FILE_HEADER FileHeader;
}

[StructLayout(LayoutKind.Explicit, Size = 40)]
public struct IMAGE_SECTION_HEADER
{
  [FieldOffset(16)]
  public UInt32 SizeOfRawData;
  [FieldOffset(20)]
  public UInt32 PointerToRawData;
}
```

::: note
PEヘッダ構造体定義で、メンバー数が膨大なものは、LayoutKind.Explicit として、Size で構造体サイズを指定、利用するメンバーのみを FieldOffset で指定しています。
不要メンバを除外することで、ソースがすっきりするし、マーシャリングオーバーヘッドの低減も期待できます。

VC++ であれば、構造体定義は winnt.h が include されていれば良く、マーシャリングもないので、すっきりしたソースコードとなります。
:::

### ZIPファイル抽出

```CSharp
// FileStream 現在位置からファイル末尾までをZIPファイルに抽出
private void MyApp2ZipFile(FileStream fsinp, string zipFile)
{
  int bufsize = 10 * 1024 * 1024;    // TODO
  byte [] bufs = new byte[bufsize];

  // ZIPファイルに抽出
  using (var fsout = new FileStream(zipFile, FileMode.Create, FileAccess.Write))
  {
    while(true)
    {
      int leng = fsinp.Read(bufs, 0, bufsize);
      if (leng == 0)
      {
        break;
      }
      fsout.Write(bufs, 0, leng);
    }
  }
}
```

## 注意事項

### 証明書付与

実行可能形式ファイルに証明書付与すると、該当情報を末尾に追記してプログラムサイズが増加しますが、前述、IMAGE_SECTION_HEDAER Section[] 最終セクションの SizeOfRawData, PointerToRawData の値は変更されません。
証明書付与後のプログラムサイズは、IMAGE_NT_HEADERS32 内OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_SECURITY] の VirtualAddress + Size で確認することができます。
また、ファイルチェックサム値が IMAGE_NT_HEADER32 の OptionalHeader.CheckSum に格納されます。

自己解凍書庫を利用する場合、ファイル結合前に「自己解凍書庫ランチャー」に証明書付与することは意味がありません。
あらかじめ「自己解凍書庫ランチャー」に証明書付与しても、ファイル末尾に「ZIPアーカイブ」を追加すると、ファイルチェックサム値が変化して、証明書付与で設定した OptionalHeader.CheckSum とは異なる値となり、チェックサム不正と判断されてしまうためです。

このため、証明書付与が必要な場合は、自己解凍書庫としてひとつのファイルに結合後、証明書付与を行うようにしてください。

::: note
ウィルス対策ソフトで、自己解凍書庫がウィルス判定されてしまうこともあるので、自己解凍書庫を配布する場合などは、証明書付与すべきです。
:::


### 管理者権限昇格

「C:\Program Files (x86)」下にファイル格納したり、HKEY_LOCAL_MACHINE レジストリを操作するようなケースでは、対象プロジェクトに「アプリケーション マニフェスト ファイル」を追加して、管理者権限昇格「level="requireAdministrator"」の指定が必要です。  


```xml:app.manifest
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
  <assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
  <trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
    <security>
      <requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
        <requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
      </requestedPrivileges>
    </security>
  </trustInfo>
----- 以降省略 -----
```

自己解凍書庫、解凍後、自動起動する実行可能形式ファイルの処理で管理者権限が必要な場合でも、「自己解凍書庫ランチャー」側で管理者権限昇格することを推奨します。
「自己解凍書庫ランチャー」が昇格していれば、「自己解凍書庫ランチャー」から起動する実行可能形式ファイルも昇格された状態を継続します。