LoginSignup
11
9

【情シスさん向け】標準ユーザーでもアプリをインストールできるようにする

Last updated at Posted at 2023-10-29

とある医療センターのランサム被害

某医療センターでは、ユーザーの多くに管理者権限を付与していたようです
このような管理は、ずさんな体制 と表現されるようですね

スマートな情シスさん(個人的見解)

管理者権限をばら撒くことが一概にダメとは言いませんが
スマートな情シスさんなら、そんなことしませんよね

限られたユーザーにだけ、管理者権限を与える のがスマートな情シスさんです✨

情シスさんの苦悩 ~管理者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化することはできない(=ラップインストーラーは管理者権限でしか作れない)ようになっていますので、ここでセキュリティを担保します

参考: IExpressを使ってbatファイルをexe化する方法

また、後述しますがファイルサイズを元にした知識暗号をつかって、悪意ある操作を防ぐ手段も考えてます

ラップインストーラーの実行方法

ラップインストーラーを実行すると、本物のインストーラー(setup.exeなど)をどこかのフォルダーに置きます
このフォルダーをサービスに監視させます
サービスは監視先のフォルダーに本物のインストーラーが置かれたら、それを実行します

標準ユーザーでログインしていたとしても、本物のインストーラーを実行するのはサービスです
(サービスは管理者権限と同じ強い権限を持っていますので、インストーラーを実行できます)

そのサービスはどうやってつくるか?
・・・C#とC++で自作します!

サービスを作る

マイクロソフトのドキュメントを参考に、C#でプログラムを書きます

あらかじめdotnetコマンドをインストールしておきましょう

コマンドプロンプトでdotnet --versionを打つとバージョンが表示されるはずです

dotnet --version
7.0.403

あとは、マイクロソフトのドキュメントにしたがってプロジェクトを作成します
作成したあとは、下記の通りC#コードを変更してください

C#コード(▶を押すと展開するよ)
MyService.csproj
<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>
Program.cs
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();
Worker.cs
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フォルダを作っておいてくださいね

コマンドプロンプト:C#コードを発行する
dotnet publish --output "C:\Temp\publish"

プログラムを発行したら、サービスに登録しましょう
コマンドプロンプトを管理者権限で開いて、下の2行を実行します

コマンドプロンプト(管理者権限):サービスを登録する
sc.exe create ".NET Joke Service" binpath="C:\Temp\publish\MyService.exe"
net start ".NET Joke Service"

無事にサービスが起動したかどうかは、サービスを見ればわかります

image.png

スタートアップの種類を自動にしておきましょう(右クリックで変更できます)
パソコンが起動したときに自動的にサービスも開始するようになります

C++のコードも必要なんです

C#のコードからはC++のコードを呼んでいます
CallProc()関数です
面倒くさいですが、C++のコードもビルドしてpublishフォルダに置いてください

C++コード(▶を押すと展開するよ)
dllmain.cpp
#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 &param, 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;
}
C#のコードからC++を呼び出しているところ
[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マイナスした値でもよい
// ただし、少数の扱いやけた落ちなどに注意すること

動作確認

  1. 管理者でサービスを起動します
  2. 標準ユーザーに切り替えます
  3. 標準ユーザーでラップインストーラー(.EXE)をダブルクリックします
  4. C:\Tempa.exea.flgが生成されます
  5. サービスがa.flgの中身とa.exeのファイルサイズを比較し、a.exeの真正性を確認します
  6. サービスが管理者権限でa.exeを実行します
  7. インストーラーが起動するので、あとは標準ユーザーがGUIから操作し、インストール作業を進めます
  8. 以上!

サービスを自動起動しておけば、手順1は不要です
マシンを再起動すれば勝手にサービスが立ち上がっている状態なので
情シスさんはラップインストーラーを標準ユーザーに渡してあげるだけで、残りのインストール作業は
常駐サービスとユーザー操作だけで完結します

後片付け

サービスを停止して、サービスリストから除名しましょう

コマンドプロンプト(管理者権限)
net stop  ".NET Joke Service"
sc.exe delete ".NET Joke Service"

おわりに

すこしC#とC++を書けば
標準ユーザーにインストール作業を委任できそうだ
ということがわかりました

「これがリスクではないか?」などコメントお待ちしております!

すべての情シスさんがすこしでも早くおうちに帰れますように!

参考リンク

お世話になりました

商用ソフト

この記事のようなインチキ手法は社内稟議を通るわけない!
という場合は、お金で解決しましょう

付録

CreateProcessAsUserはUNICODEの使用/不使用によって実装が変わるようです
末尾AWで分かれます

今回のような日本語を使わないパスしか使わないのであれば
末尾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.exea.batで置換する
  • a.batのファイルサイズをプロパティで確認して、その倍の値をa.flgに書き込んでおく
  • a.bat実行時、黒いDOS窓が開いてすぐに閉じてしまうと、ユーザーが「あれ?今動いたかな?」と戸惑ってしまうので、a.batの末尾にpauseを入れておくと親切💛

PING応答に応じてbatファイルの処理を分岐する

たとえば監視対象のホストからPING応答があった場合にのみ何かの処理をする場合
:erroroutのところにPING応答があった場合の処理を書く

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

11
9
9

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
9