MonexMarketStation の二重起動制限をバイナリパッチで解除する
マネックス証券の取引ツール MonexMarketStation は、同じPCで複数のアカウントを使いたい場合などに二重起動が必要になることがある。しかし標準では二重起動防止が組み込まれている。
今回、バイナリを解析してどこをどう変えれば解除できるかを調べ、バージョンアップにも追従できる PowerShell パッチスクリプト を作った。
調査:差分はどこか
cmp -l で元ファイルとパッチ済みファイルを比較すると、差分はたった1箇所だった。
ファイルオフセット: 0x39E1B(インポートテーブル内)
hexdump で前後を確認:
# 変更前(元ファイル)
00039e10: 65 4d 75 74 65 78 00 00 9b 00 43 72 65 61 74 65 eMutex....Create
00039e20: 4d 75 74 65 78 41 00 00 ea 00 45 6e 63 6f 64 65 MutexA....Encode
# 変更後(パッチ済み)
00039e10: 65 4d 75 74 65 78 00 00 9b 00 4f 70 65 6e 4d 75 eMutex....OpenMu
00039e20: 74 65 78 41 00 00 00 00 ea 00 45 6e 63 6f 64 65 texA......Encode
PE バイナリの Import Name Table(インポートテーブル) 内の関数名文字列が書き換えられていた。
| 元ファイル | パッチ後 | |
|---|---|---|
| API 関数名 | CreateMutexA |
OpenMutexA |
| バイト数 | 13 bytes | 13 bytes(同一) |
仕組み:なぜこれだけで効くのか
元の二重起動防止ロジック(推定):
HANDLE h = CreateMutexA(NULL, TRUE, "MonexMarketStation");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
ExitProcess(0); // 既に起動中 → 終了
}
| API | 動作 |
|---|---|
CreateMutexA |
Mutex を新規作成。既存なら ERROR_ALREADY_EXISTS → 二重起動と判定して終了 |
OpenMutexA |
既存 Mutex を開くだけ。誰も CreateMutex していないので常に NULL 返却 → チェックをすり抜けて起動継続 |
インポートテーブルの関数名を書き換えるだけで、コードフローを一切変えずに制限を無効化できる。変更量は 13 バイトのみ。
パッチスクリプト(PowerShell)
バージョンアップ後の新しい EXE にも対応できるよう、CreateMutexA\0 をバイナリ全文検索して OpenMutexA\0\0\0(同じ13バイト)に置換するスクリプトを作った。
- 既存の出力ファイルは 退避元ファイルの更新日時をプレフィックス にしてバックアップ(例:
20241115.multi_MonexMarketStation.exe) - 検索0件の場合はパッチ済み判定 or 警告を表示して終了(exit 2)
#Requires -Version 5.1
<#
.SYNOPSIS
MonexMarketStation.exe 二重起動解除パッチスクリプト
.PARAMETER TargetPath
パッチを当てる EXE のパス(省略時は自動検出)
.PARAMETER OutputPath
出力先パス(省略時は同フォルダに multi_<元ファイル名>)
.PARAMETER Force
上書き確認をスキップ
#>
param(
[string]$TargetPath,
[string]$OutputPath,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# ---- 定数 ----------------------------------------------------------------
$SEARCH_STR = "CreateMutexA"
$REPLACE_STR = "OpenMutexA"
# CreateMutexA\0 = 13 bytes
# OpenMutexA\0\0\0 = 13 bytes ← ファイルサイズ変化なし
$SEARCH_BYTES = [System.Text.Encoding]::ASCII.GetBytes($SEARCH_STR) + [byte[]](0x00)
$REPLACE_BYTES = [System.Text.Encoding]::ASCII.GetBytes($REPLACE_STR) + [byte[]](0x00, 0x00, 0x00)
# ---- ヘルパー ------------------------------------------------------------
function Find-BytePattern([byte[]]$Haystack, [byte[]]$Needle) {
$results = [System.Collections.Generic.List[int]]::new()
$nLen = $Needle.Length; $hLen = $Haystack.Length
for ($i = 0; $i -le $hLen - $nLen; $i++) {
$ok = $true
for ($j = 0; $j -lt $nLen; $j++) {
if ($Haystack[$i+$j] -ne $Needle[$j]) { $ok = $false; break }
}
if ($ok) { $results.Add($i) }
}
return ,$results
}
function Show-Context([byte[]]$Data, [int]$Pos, [int]$PatLen, [string]$Label, [string]$Color) {
$s = [Math]::Max(0, $Pos-4); $e = [Math]::Min($Data.Length-1, $Pos+$PatLen+3)
$hex = $Data[$s..$e] | ForEach-Object { '{0:X2}' -f $_ }
Write-Host (" 0x{0:X8}: {1} <- {2}" -f $Pos, ($hex -join " "), $Label) -ForegroundColor $Color
}
# ---- パス解決 ------------------------------------------------------------
if (-not $TargetPath) {
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$candidates = @(
(Join-Path $scriptDir "MonexMarketStation.exe"),
(Join-Path $scriptDir "..\module\MonexMarketStation.exe"),
"C:\Program Files (x86)\monex_ms\module\MonexMarketStation.exe"
)
foreach ($c in $candidates) {
$full = [System.IO.Path]::GetFullPath($c)
if (Test-Path $full) { $TargetPath = $full; break }
}
if (-not $TargetPath) { Write-Error "対象ファイルが見つかりません"; exit 1 }
}
if (-not (Test-Path $TargetPath)) { Write-Error "ファイルが見つかりません: $TargetPath"; exit 1 }
$targetDir = Split-Path -Parent $TargetPath
$targetName = Split-Path -Leaf $TargetPath
if (-not $OutputPath) { $OutputPath = Join-Path $targetDir ("multi_" + $targetName) }
# ---- ヘッダー ------------------------------------------------------------
Write-Host ""
Write-Host "=== MonexMarketStation 二重起動解除パッチ ===" -ForegroundColor Cyan
Write-Host (" 対象 : {0} ({1:#,0} bytes)" -f $TargetPath, (Get-Item $TargetPath).Length)
Write-Host " 出力 : $OutputPath"
Write-Host ""
# ---- バックアップ(退避元ファイルの更新日をプレフィックスに)-----------
if (Test-Path $OutputPath) {
$datePrefix = (Get-Item $OutputPath).LastWriteTime.ToString("yyyyMMdd")
$outDir = Split-Path -Parent $OutputPath
$outName = Split-Path -Leaf $OutputPath
$backupPath = Join-Path $outDir ($datePrefix + "." + $outName)
$seq = 1
while (Test-Path $backupPath) {
$backupPath = Join-Path $outDir ("{0}_{1}.{2}" -f $datePrefix, $seq, $outName)
$seq++
}
if (-not $Force) {
$ans = Read-Host "出力先が既に存在します。バックアップして上書きしますか? [y/N]"
if ($ans -notmatch '^[Yy]') { Write-Host "キャンセル"; exit 0 }
}
Copy-Item -Path $OutputPath -Destination $backupPath
Write-Host "[backup] $backupPath" -ForegroundColor DarkGray
}
# ---- 読み込み ------------------------------------------------------------
Write-Host "[1/4] ファイル読み込み中..." -NoNewline
$data = [System.IO.File]::ReadAllBytes($TargetPath)
Write-Host (" OK ({0:#,0} bytes)" -f $data.Length)
# ---- 検索 ----------------------------------------------------------------
Write-Host "[2/4] '$SEARCH_STR' を検索中..." -NoNewline
$positions = Find-BytePattern -Haystack $data -Needle $SEARCH_BYTES
if ($positions.Count -eq 0) {
Write-Host ""
$already = Find-BytePattern -Haystack $data -Needle $REPLACE_BYTES
if ($already.Count -gt 0) {
Write-Host "[!] 既にパッチ適用済みです($REPLACE_STR を検出)" -ForegroundColor Yellow
$already | ForEach-Object { Show-Context $data $_ $REPLACE_BYTES.Length "Already patched" "Yellow" }
} else {
Write-Host "[!] '$SEARCH_STR' が見つかりません。バージョン変更の可能性あり" -ForegroundColor Red
exit 2
}
exit 0
}
Write-Host " $($positions.Count) 箇所検出"
$positions | ForEach-Object { Show-Context $data $_ $SEARCH_BYTES.Length "Before" "Gray" }
# ---- パッチ適用 ----------------------------------------------------------
Write-Host "[3/4] パッチ適用中..."
foreach ($pos in $positions) {
for ($j = 0; $j -lt $REPLACE_BYTES.Length; $j++) { $data[$pos+$j] = $REPLACE_BYTES[$j] }
Show-Context $data $pos $REPLACE_BYTES.Length "After " "Green"
}
# ---- 書き出し ------------------------------------------------------------
Write-Host "[4/4] 出力中..." -NoNewline
[System.IO.File]::WriteAllBytes($OutputPath, $data)
Write-Host " OK -> $OutputPath"
Write-Host ""
Write-Host "=== パッチ完了 ===" -ForegroundColor Green
Write-Host " $($positions.Count) 箇所を置換 : $SEARCH_STR --> $REPLACE_STR"
Write-Host " 出力 : $OutputPath"
Write-Host ""
使い方
# ① 引数なし(インストール先を自動検出)
.\patch_monex_multi.ps1
# ② 新バージョンのパスを明示
.\patch_monex_multi.ps1 -TargetPath "C:\...\MonexMarketStation.exe"
# ③ 出力先も指定して強制上書き
.\patch_monex_multi.ps1 -TargetPath "...\MonexMarketStation.exe" `
-OutputPath "...\multi_MonexMarketStation.exe" -Force
バージョンアップ時は、新しい MonexMarketStation.exe に対してそのまま実行するだけでよい。CreateMutexA が見つからない場合(実装が変わった場合)は警告を出して終了するので、適用可否が一目でわかる。
バックアップのネーミング
既存の multi_MonexMarketStation.exe を上書きする際、そのファイル自身の更新日時をプレフィックスに付けてバックアップする。
20241115.multi_MonexMarketStation.exe ← 旧バージョン由来のパッチ済みファイル
20241115_1.multi_MonexMarketStation.exe ← 同日2回目以降は連番
multi_MonexMarketStation.exe ← 最新(今回のパッチ適用結果)
「どのバージョンのMonexMarketStationからパッチしたものか」がファイル名だけで分かる。
まとめ
| 項目 | 内容 |
|---|---|
| 変更箇所 | インポートテーブル(kernel32.dll インポート) |
| 変更量 | 13 バイトのみ |
| 手法 | API 関数名の文字列置換(コードフロー変更なし) |
| 効果 | Mutex ベースの単一インスタンス制限を無効化 |
バイナリパッチの教科書的な例として、インポートテーブルの関数名書き換えという手法の参考にもなるかと思う。