とある医療センターのランサム被害
某医療センターでは、ユーザーの多くに管理者権限を付与していたようです
このような管理は、ずさんな体制 と表現されるようですね
スマートな情シスさん(個人的見解)
管理者権限をばら撒くことが一概にダメとは言いませんが
スマートな情シスさんなら、そんなことしませんよね
限られたユーザーにだけ、管理者権限を与える のがスマートな情シスさんです✨
情シスさんの苦悩 ~管理者1人 vs 膨大な端末~
では、なぜ広く多くのユーザーに管理者権限を与えることになるのか?
その理由の1つは、いちいち情シスさんが出向いて端末操作してられないからです
たとえば、ソフトのインストールは、管理者権限が無いとできない操作です
- ソフトをインストールしたい人が現れるたび、情シスさんが端末を操作しに行きます
- ソフトをインストールした人がたくさんいても、情シスさんは1人か2人です
- 大きな企業でも情シスさんは10人くらいです(要出典)
・・・人手が足りないですね
じゃあ、もうソフトのインストールは自分でやってもらいましょう!
ということで、管理者権限を全ユーザーにばら撒くことになるのです
某医療センターの実情はいざ知らず、世間の情シスさんは大変なのです
標準ユーザーでもソフトをインストールできたらイイね😀
標準ユーザーでログインしていても、ソフトをインストールするときだけ管理者権限を使えたらいいですね
そんなことできるのでしょうか?
標準ユーザーには管理者権限のパスワードがバレてはいけません
SYSTEMユーザー(管理者権限あり)にインストールさせよう
Windowsマシンでは、常にバックグラウンドでサービスが動いています
ユーザーの目では見えないところで、せっせとサービスは動いています
サービスは標準ユーザーよりも強い権限で動いています
「System」はAdministratorsグループのメンバと同等の権限を持つアカウントで、システムへのフルアクセスが可能だ。
よし、ソフトのインストールをサービスに任せたらいいのだ!
Systemアカウントは強力な権限を持つため、攻撃者にサービスの脆弱(ぜいじゃく)性を突かれて乗っ取られた際、被害が甚大になりかねないです。自己責任でお願いします。
免責事項
以下のコードで何か不具合が起きても、私は一切の責任をとれません
★基本方針★
標準ユーザーにインストールしてほしいソフトのインストーラーを用意します
よくあるのがsetup.exe
というファイルです
これをWindows標準ソフトであるIExpressでEXE化します
(以下このEXE化したものをラップインストーラーと呼びます)
このラップインストーラーを標準ユーザーがダブルクリックすると、本物のインストーラー(setup.exe
)が起動する算段です
セキュリティリスクについて
誰でもsetup.exe
をIExpressでEXE化したら、任意のsetup.exe
を走らせることができて、悪意のあるソフトをユーザーがインストールするリスクがありますね
でも大丈夫です!
IExpressを使用するためには管理者権限が必要です
誰それ構わずEXE化することはできない(=ラップインストーラーは管理者権限でしか作れない)ようになっていますので、ここでセキュリティを担保します
また、後述しますがファイルサイズを元にした知識暗号をつかって、悪意ある操作を防ぐ手段も考えてます
ラップインストーラーの実行方法
ラップインストーラーを実行すると、本物のインストーラー(setup.exe
など)をどこかのフォルダーに置きます
このフォルダーをサービスに監視させます
サービスは監視先のフォルダーに本物のインストーラーが置かれたら、それを実行します
標準ユーザーでログインしていたとしても、本物のインストーラーを実行するのはサービスです
(サービスは管理者権限と同じ強い権限を持っていますので、インストーラーを実行できます)
そのサービスはどうやってつくるか?
・・・C#とC++で自作します!
サービスを作る
マイクロソフトのドキュメントを参考に、C#でプログラムを書きます
あらかじめdotnet
コマンドをインストールしておきましょう
コマンドプロンプトでdotnet --version
を打つとバージョンが表示されるはずです
dotnet --version
7.0.403
あとは、マイクロソフトのドキュメントにしたがってプロジェクトを作成します
作成したあとは、下記の通りC#コードを変更してください
C#コード(▶を押すと展開するよ)
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-MyService-24f66dd5-0856-40e4-a83b-0086ec7054b5</UserSecretsId>
<OutputType>exe</OutputType>
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PlatformTarget>x64</PlatformTarget>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.1" />
</ItemGroup>
</Project>
using MyService;
using Microsoft.Extensions.Logging.Configuration;
using Microsoft.Extensions.Logging.EventLog;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddWindowsService(options => options.ServiceName = ".NET Joke Service");
LoggerProviderOptions.RegisterProviderOptions<
EventLogSettings, EventLogLoggerProvider>(builder.Services);
builder.Services.AddHostedService<Worker>();
IHost host = builder.Build();
host.Run();
namespace MyService;
using System;
using System.IO;
using System.Runtime.InteropServices;
public class Worker : BackgroundService
{
[DllImport("Dll1.dll")]
public static extern void CallProc();
private readonly ILogger<Worker> _logger;
private readonly string _flgFileName = "a.flg";
private readonly string _exeFileName = "a.exe";
public Worker(ILogger<Worker> logger) => _logger = logger;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
string aflg = Path.Combine("c:/Temp", _flgFileName);
string aexe = Path.Combine("c:/Temp", _exeFileName);
try
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
if (Path.Exists(aflg))
{
_logger.LogWarning("flag file exists");
var file = new FileInfo(aexe);
long size = file.Length;
_logger.LogInformation("file size1: {0} bytes", size);
using (var sr = new StreamReader(aflg))
{
string line = sr.ReadLine();
if (long.TryParse(line, out long s))
{
_logger.LogInformation("file size*2: {0} bytes", s);
// ★ flgファイルの内容が正当でなければ
// 悪意ある操作とみなして、インストーラーを実行させない
if (size * 2 == s)
{
CallProc();
}
}
}
File.Delete(aflg);
_logger.LogInformation("flag file deleted");
}
else if (Path.Exists(aexe))
{
try
{
File.Delete(aexe);
_logger.LogInformation("exe file deleted");
}
catch (UnauthorizedAccessException)
{
_logger.LogInformation("exe file access denied");
}
catch (IOException)
{
_logger.LogInformation("exe file in use");
}
}
_logger.LogInformation("wait five minutes");
await Task.Delay(5000, stoppingToken);
}
}
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "{Message}", ex.Message);
Environment.Exit(1);
}
}
}
サービスをWindowsマシンで動かす
コードが書けたら、プログラムを発行(publish
)しましょう
あらかじめCドライブ直下にTemp
フォルダを作っておいてくださいね
dotnet publish --output "C:\Temp\publish"
プログラムを発行したら、サービスに登録しましょう
コマンドプロンプトを管理者権限で開いて、下の2行を実行します
sc.exe create ".NET Joke Service" binpath="C:\Temp\publish\MyService.exe"
net start ".NET Joke Service"
無事にサービスが起動したかどうかは、サービスを見ればわかります
スタートアップの種類を自動にしておきましょう(右クリックで変更できます)
パソコンが起動したときに自動的にサービスも開始するようになります
C++のコードも必要なんです
C#のコードからはC++のコードを呼んでいます
CallProc()
関数です
面倒くさいですが、C++のコードもビルドしてpublish
フォルダに置いてください
C++コード(▶を押すと展開するよ)
#include <windows.h>
#include <string>
#include <iostream>
#include <windows.h>
#include <tlhelp32.h>
#include <tchar.h>
extern "C" __declspec(dllexport) void __stdcall CallProc();
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
BOOL createProcessAsUser(const std::wstring &app, const std::wstring ¶m, HANDLE token, DWORD creationFlags)
{
wchar_t arg[MAX_PATH] = L"";
wcscpy_s(arg, (param.empty() ? app.c_str() : (app + L" " + param).c_str()));
WCHAR tmp[64] = L"winsta0\\default";
STARTUPINFO si = {sizeof(STARTUPINFO), nullptr, tmp};
PROCESS_INFORMATION pi = {};
const BOOL retval = CreateProcessAsUser(token, nullptr, arg, nullptr, nullptr, FALSE, creationFlags, nullptr, nullptr, &si, &pi);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return retval;
}
void __stdcall CallProc()
{
DWORD dwSesId = ::WTSGetActiveConsoleSessionId();
DWORD currentProcessId = GetCurrentProcessId();
std::cout << "currentProcessId: " << currentProcessId << std::endl;
HANDLE hProcess = OpenProcess(MAXIMUM_ALLOWED, false, currentProcessId);
HANDLE hPToken = NULL;
if (!OpenProcessToken(hProcess, TOKEN_DUPLICATE, &hPToken))
{
std::cout << "err1" << std::endl;
CloseHandle(hProcess);
return;
}
SECURITY_ATTRIBUTES sa = {0};
sa.nLength = sizeof(sa);
HANDLE hUserTokenDup = NULL;
if (!DuplicateTokenEx(hPToken, MAXIMUM_ALLOWED, &sa, SecurityIdentification, TokenPrimary, &hUserTokenDup))
{
std::cout << "err2" << std::endl;
CloseHandle(hProcess);
CloseHandle(hPToken);
return;
}
STARTUPINFO si = {0};
si.cb = sizeof(si);
DWORD creationFlags = CREATE_NEW_CONSOLE | NORMAL_PRIORITY_CLASS;
BOOL _ = SetTokenInformation(hUserTokenDup, TokenSessionId, &dwSesId, sizeof(DWORD));
BOOL retval = createProcessAsUser(L"c:\\Temp\\a.exe", L"", hUserTokenDup, creationFlags);
std::cout << "retval:" << retval << std::endl;
}
[DllImport("Dll1.dll")]
public static extern void CallProc();
DLLファイルを作って、publish
にコピーします
コードが書けたら、Dll1.dll
という名前で実行ファイルを作ります
作ったファイルはC:\Temp\publish
に手でコピーしてください
g++ dllmain.cpp -o Dll1.dll -shared -DUNICODE
もしg++
コマンドが使えない場合は、下記のサイトなどを参考にしましょう
なぜC++を使うのか?
C#だけで完結しないのか?
セッション 0 分離 | Windows 7というサイト様から2段落引用します
・Windows 7ではプロセスごとにセッション ID が割り当てられますが、サービスやシステムとして起動するプロセスは セッション ID =0 として起動します。一方各ユーザが起動するプロセスは セッション ID =1以上として起動します。
またセッション 0 はユーザインターフェース(Windows のポップアップ画面など)を表示させることは出来なくなりました。よってサービスプログラムがユーザにポップアップ画面を表示することは出来なくなりました。
サービスが起動したプロセスは、画面が表示されないのですね
(実際にやってみましたが、タスクマネージャーにプロセスはあるものの、画面はたしかに表示されませんでした)
画面が見れないとユーザーは後続のインストール作業ができません
画面を表示するためには、ユーザーセッションでインストーラーを実行する必要があり
その関数を簡単に提供してくれるのがC++ だったので、今回C++を使いました
参考リンクではC#で同機能を実現している例もありますが
私のプログラミングスキルではC#での実装は難しかったです
長くなりましたが、以上が今回C++を使った理由です
あと一息、本物インストーラーをラップしたEXEをつくります
IExpressで本物インストーラーとコマンドファイルをラップします
標準ユーザーがダブルクリックする対象となるラップインストーラーをつくる工程です
IExpressでEXEファイルを作って、そのEXEをたたくと
その中にいる本物のインストーラー(a.exe
)が実行される
という算段です
CMDコード(▶を押すと展開するよ)
@REM 2023.10
echo "IEXPRESS: extract the installer"
powershell sleep 1
if exist c:\Temp\a.flg (
echo flg file exists, exit
powershell sleep 1
exit
)
if exist c:\Temp\a.exe (
echo exe file exists, exit
powershell sleep 1
exit
)
copy a.exe c:\Temp\
copy a.flg c:\Temp\
@REM powershell (Get-Item a.exe).Length > c:\Temp\a.flg
@REM type nul > %TEMP%\a.flg
echo "IEXPRESS: finish to 1 sec"
powershell sleep 1
@REM REM pause
先に何かインストーラーら動いていないか、flg
ファイルの有無で確認します
インストーラーは1つずつ実行してもらう必要があります
悪意のある行動への対処
flg
ファイルにはあらかじめ管理者がこっそり
インストーラーサイズの2倍の値を書き込んでおきます
これはインストーラーが確かにIExpressで包んだインストーラーであることを確かめるための安全確認です
C#サービスでも、同様にインストーラーのファイルサイズを確認し
その2倍の値がflg
ファイルの内容と合致することを確認します
悪意を持った標準ユーザーが、サービスの監視フォルダにインストーラー(setup.exe
のようなもの)とflg
ファイルを置くかもしれません
サービスは監視対象フォルダにインストーラーがあれば実行します
その時に、フラグファイルの中身がインストーラーサイズの2倍でない場合は
インストーラーを破棄します!!
このような仕掛けを入れておくことで、手作業によるインストーラーの配置を無効にします
もちろんこのN倍は管理者だけで共有すべき秘密事項です
ちゃんとしたセキュリティ
電子署名やら鍵暗号などでガッチリと正当なインストーラーを判別すればより安全ですが
そんな大鉈を振るわなくとも、「ファイルサイズのN倍の値がflg
に書き込まれている」くらいの
約束だけでもひとまずは人間の脳内プロセッサでは破れないくらいのセキュリティはつくれます
// 元のファイルサイズの2倍の値が flg ファイルに書き込まれていたら
// C++の処理に移動し、実際にインストール作業を実施する
if (size * 2 == s)
{
CallProc();
}
// 簡単のために2倍を採用したが、17倍だろうが、8倍して2マイナスした値でもよい
// ただし、少数の扱いやけた落ちなどに注意すること
動作確認
- 管理者でサービスを起動します
- 標準ユーザーに切り替えます
- 標準ユーザーでラップインストーラー(.EXE)をダブルクリックします
-
C:\Temp
にa.exe
とa.flg
が生成されます - サービスが
a.flg
の中身とa.exe
のファイルサイズを比較し、a.exe
の真正性を確認します - サービスが管理者権限で
a.exe
を実行します - インストーラーが起動するので、あとは標準ユーザーがGUIから操作し、インストール作業を進めます
- 以上!
サービスを自動起動しておけば、手順1は不要です
マシンを再起動すれば勝手にサービスが立ち上がっている状態なので
情シスさんはラップインストーラーを標準ユーザーに渡してあげるだけで、残りのインストール作業は
常駐サービスとユーザー操作だけで完結します
後片付け
サービスを停止して、サービスリストから除名しましょう
net stop ".NET Joke Service"
sc.exe delete ".NET Joke Service"
おわりに
すこしC#とC++を書けば
標準ユーザーにインストール作業を委任できそうだ
ということがわかりました
「これがリスクではないか?」などコメントお待ちしております!
すべての情シスさんがすこしでも早くおうちに帰れますように!
参考リンク
お世話になりました
- Visual Studio Code を使用して .NET コンソール アプリケーションを作成する
- Systemセッションから、Userセッションでアプリを起動する①
- Systemセッションから、Userセッションでアプリを起動する②
- C#から呼び出せるC++の関数の作成と、それを呼び出す方法
- セッション 0 分離 | Windows 7
- how to "use unicode character set" in g++?
商用ソフト
この記事のようなインチキ手法は社内稟議を通るわけない!
という場合は、お金で解決しましょう
付録
CreateProcessAsUser
はUNICODEの使用/不使用によって実装が変わるようです
末尾A
とW
で分かれます
今回のような日本語を使わないパスしか使わないのであれば
末尾A
のほうがシンプルで扱いやすいのですが、、、
それは今後の課題ということで。
CreateProcessAsUserA(
_In_opt_ HANDLE hToken,
_In_opt_ LPCSTR lpApplicationName,
_Inout_opt_ LPSTR lpCommandLine,
_In_opt_ LPSECURITY_ATTRIBUTES lpProcessAttributes,
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
_In_ BOOL bInheritHandles,
_In_ DWORD dwCreationFlags,
_In_opt_ LPVOID lpEnvironment,
_In_opt_ LPCSTR lpCurrentDirectory,
_In_ LPSTARTUPINFOA lpStartupInfo,
_Out_ LPPROCESS_INFORMATION lpProcessInformation
);
#ifdef UNICODE
#define CreateProcessAsUser CreateProcessAsUserW
#endif
CreateProcessAsUserW(
_In_opt_ HANDLE hToken,
_In_opt_ LPCWSTR lpApplicationName,
_Inout_opt_ LPWSTR lpCommandLine,
_In_opt_ LPSECURITY_ATTRIBUTES lpProcessAttributes,
_In_opt_ LPSECURITY_ATTRIBUTES lpThreadAttributes,
_In_ BOOL bInheritHandles,
_In_ DWORD dwCreationFlags,
_In_opt_ LPVOID lpEnvironment,
_In_opt_ LPCWSTR lpCurrentDirectory,
_In_ LPSTARTUPINFOW lpStartupInfo,
_Out_ LPPROCESS_INFORMATION lpProcessInformation
);
関数 | Typedef | 定義 |
---|---|---|
末尾A
|
LPCSTR | const char* |
末尾W
|
LPCWSTR | const wchar_t* |
糸冬了!!
追記(2024.02.13)
-
a.exe
ではなくバッチファイルを管理者権限で実行したい場合は、a.exe
をa.bat
で置換する -
a.bat
のファイルサイズをプロパティで確認して、その倍の値をa.flg
に書き込んでおく -
a.bat
実行時、黒いDOS窓が開いてすぐに閉じてしまうと、ユーザーが「あれ?今動いたかな?」と戸惑ってしまうので、a.bat
の末尾にpause
を入れておくと親切💛
PING応答に応じてbatファイルの処理を分岐する
たとえば監視対象のホストからPING応答があった場合にのみ何かの処理をする場合
:errorout
のところにPING応答があった場合の処理を書く
@echo off
set COUNT=0
:error
set /a COUNT=COUNT+1
echo COUNTDOWN ... %COUNT%/3
if "%COUNT%" == "3" goto errorout
ping -n 1 192.168.10.1 | find "ms TTL=" > NUL
if ERRORLEVEL 1 goto notrespond
timeout /t 5 > nul
goto error
:notrespond
echo (PING ERROR) CANNOT RESTART HOST
goto end
:errorout
echo (OK) RESTART 192.168.10.1
:end
echo ---END---
pause