2022年12月22日

ユーザを偽装してSYSTEMサービスからネットワークリソースにアクセスする

このアーティクルはDelphi Advent Calendar 2022の22日目の記事です(20日ぶり8回目)。

以前WNetAddConnection2を使って他のPCのネットワーク共有にアクセスする方法について書きましたが、これをサービスから実行するとWNetAddConnection2が時々エラー1312(ERROR_NO_SUCH_LOGON_SESSION)になる、という現象が発生します。調べてみたところ、サービスの場合はSystemではなくNetworkServiceで動作していないとこのような状態になるようです(常にエラーになるわけではなく、成功したりエラーになったりという感じ)。もちろんサービスをNetworkServiceで実行すればネットワークアクセスでエラーになることはないのですが、ローカルコンピュータに対するアクセスがUsersグループ相当に制限されてしまいます(サービスで使用する(Local)System/LocalService/NetworkServiceアカウントについてはWindowsテクニカルドキュメント(旧MSDN)のService User AccountsLocalService AccountNetworkService AccountLocalSystem Accountや@ITのWindowsのサービスで使用される「System」「Local Service」「Network Service」アカウントとは?:Tech TIPSを参照)。

そこでSystemアカウントで動作するサービスから実行することを前提に、ネットワークリソースにアクセスするときだけNetworkServiceアカウントに偽装するようにしてみます。
まずLogonUserでNetworkServiceとしてログインし、返されたトークンハンドルでImpersonateLoggedOnUserを呼び出すことでユーザを偽装します。
const
  LOGON32_LOGON_NEW_CREDENTIALS   = 9;
  {$EXTERNALSYM LOGON32_LOGON_NEW_CREDENTIALS}
var
  LogonUserUser: String;
  LogonUserDomain: String;
  LogonUserLogonType: DWORD;
  LogonUserLogonProvider: DWORD;
  hToken: THandle;
begin
  { Logon as 'NetworkService' user }
  LogonUserUser := 'NetworkService';
  LogonUserDomain := 'NT AUTHORITY';
  LogonUserLogonType := LOGON32_LOGON_NEW_CREDENTIALS;
  LogonUserLogonProvider := LOGON32_PROVIDER_DEFAULT;
  if LogonUser(PChar(LogonUserUser),PChar(LogonUserDomain),nil,
               LogonUserLogonType,LogonUserLogonProvider,hToken) = False then
  begin
    RaiseLastOSError;
  end;

  { Impersonate }
  if ImpersonateLoggedOnUser(hToken) = False then
  begin
    RaiseLastOSError;
  end;
これでこの後WNetAddConnection2で接続を試みるときにSystemアカウントではなくNetworkServiceアカウントであるかのように扱われます。

切断するときはまずWNetCancelConnection2を呼び出した後で、RevertToSelfで偽装を解除し、CloseHandleでトークンハンドルをクローズすることでログオフします。
  { Revert impersonation }
  if RevertToSelf() = False then
  begin
    RaiseLastOSError;
  end;

  { Loggoff }
  CloseHandle(hToken);
  hToken := INVALID_HANDLE_VALUE;
なおプロセス上で複数のスレッドが動作している場合(特にサービス)など、同一の共有リソース('\\<computername>\<sharename>')に対してWNetAddConnection2をネストして呼び出すとERROR_ALREADY_ASSIGNEDでエラーになります。これを防ぐには同一の共有リソースに対してWNetAddConnection2/WNetCancelConnection2が1回ずつ呼び出されるように、接続している共有リソースをプロセス全体で適切に管理する必要もあります。

2022年12月5日

2022年12月2日

InterBase 2020 Update 4

InterBase 2020 Update 4がリリースされています。バージョンは14.4.0.804となっています。

InterBase 2020U4 リリースノート - InterBase (en)
InterBase 2020 Update 4 の新機能 - InterBase (en)
Resolved Defects - InterBase (en)

Embarcadero InterBase 2020 Update 4 のリリース (en)

レコード型のフィールドのオフセットを取得する

このアーティクルはDelphi Advent Calendar 2022の2日目の記事です(3年ぶり7回目)。

Delphiのレコード型(Cの構造体に相当)は、スタックに配置することができるということ以外にも、特定のメモリレイアウトを定義することができることから、固定長ファイルや指定されたメモリ上のデータを解釈するために使われることがあります。このような場合に、定義したレコード型の特定のフィールドのオフセットが(主にデバッグ用に)欲しくなったりするのですが、Delphiには(SizeOfはあるのに)C/C++のoffsetofマクロ(stddef.h)のようなものが存在しません(RSP-39559)。そこで軽く検索してみたところStack Overflowにそのものずばりな投稿がありました。

delphi - Get Position of a struct var AKA Offset of record field - Stack Overflow
Delphi: Offset of record field - Stack Overflow

ではちょっと試してみましょう。まずレコード型とそのポインタ型を定義します。
type
  TFoo1 = record
    Bar: Boolean;
    Baz: Double;
    Qux: array [0..8] of Byte;
    Quux: Integer;
  end;
  PFoo1 = ^TFoo1;
これで
procedure TForm1.Button1Click(Sender: TObject);
begin
  Memo1.Lines.Add(Format('TFoo1: SizeOf=%d',[SizeOf(TFoo1)]));
  Memo1.Lines.Add(Format('TFoo1.Bar: offset=%d',[NativeUInt(@(PFoo1(nil)^.Bar))]));
  Memo1.Lines.Add(Format('TFoo1.Baz: offset=%d',[NativeUInt(@(PFoo1(nil)^.Baz))]));
  Memo1.Lines.Add(Format('TFoo1.Qux: offset=%d',[NativeUInt(@(PFoo1(nil)^.Qux))]));
  Memo1.Lines.Add(Format('TFoo1.Quux: offset=%d',[NativeUInt(@(PFoo1(nil)^.Quux))]));
end;
のように、nilをレコード型へのポインタにキャスト→フィールドを参照→そのアドレスを取得→整数に変換とすることで、そのフィールドのオフセット値を取得できます(アドレスと同じサイズの符号なし整数はNativeUInt型)。では実行してみましょう。

TFoo1: SizeOf=32
TFoo1.Bar: offset=0
TFoo1.Baz: offset=8
TFoo1.Qux: offset=16
TFoo1.Quux: offset=28
Delphiのレコード型のデフォルトのアライメントマスクに従って配置されていることがわかります(TFoo1.QuuxのオフセットがDelphiのデフォルトのフィールドのアライメント(構造体アライメント)の8バイトに従った32ではなく、型(Integer)のサイズである4バイトで28になっていることに注意)。
しかし最初に書いたように、特定の(外部で規定された)メモリレイアウトに簡単にアクセスできるようにするためにレコード型を使用する、という目的を考えると、レコード型にはpackedを指定して、明示的にパディングするような場合が多いと思われます。ではレコード型をpacked recordにしてみましょう。
type
  TFoo2 = packed record
    Bar: Boolean;
    Baz: Double;
    Qux: array [0..8] of Byte;
    Quux: Integer;
  end;
  PFoo2 = ^TFoo2;

procedure TForm1.Button2Click(Sender: TObject);
begin
  Memo1.Lines.Add(Format('TFoo2: SizeOf=%d',[SizeOf(TFoo2)]));
  Memo1.Lines.Add(Format('TFoo2.Bar: offset=%d',[NativeUInt(@(PFoo2(nil)^.Bar))]));
  Memo1.Lines.Add(Format('TFoo2.Baz: offset=%d',[NativeUInt(@(PFoo2(nil)^.Baz))]));
  Memo1.Lines.Add(Format('TFoo2.Qux: offset=%d',[NativeUInt(@(PFoo2(nil)^.Qux))]));
  Memo1.Lines.Add(Format('TFoo2.Quux: offset=%d',[NativeUInt(@(PFoo2(nil)^.Quux))]));
end;
実行してみます。

TFoo2: SizeOf=22
TFoo2.Bar: offset=0
TFoo2.Baz: offset=1
TFoo2.Qux: offset=9
TFoo2.Quux: offset=18
意図通り、すべてのフィールドがパディングされることなく並んでいることがわかります。

逆アセンブルを見ると、コンパイラは各メンバのオフセットを知っててnil(=0)に加算しているので、最初からこれをもらう方法があれば…とは思います。また上記のStack Overflowの2番目の投稿にはRTTIを使った方法も紹介されていますが、RTTIのテーブルでループを回しながら文字列の比較をする必要があるため、それに比べればnilを使った方法のほうが優れていると考えられます。

ところで、packedを指定せずデフォルトのアライメントマスクに従って配置された場合、順序型はそのサイズでアライメントされる、との記述があります。つまり
type
  TFoo3 = record
    Bar: array [0..8] of Byte;
    Baz: Boolean;
    Qux: array [0..8] of Byte;
  end;
  PFoo3 = ^TFoo3;
のように1バイトアライメントされている配列のフィールドの次に1バイトの順序型のフィールドがあると、デフォルトのアライメントの8バイトに従ったパディングが行われることなく、連続した配置になる、ということになります(逆も同じ)。
procedure TForm1.Button3Click(Sender: TObject);
begin
  Memo1.Lines.Add(Format('TFoo3: SizeOf=%d',[SizeOf(TFoo3)]));
  Memo1.Lines.Add(Format('TFoo3.Bar: offset=%d',[NativeUInt(@(PFoo3(nil)^.Bar))]));
  Memo1.Lines.Add(Format('TFoo3.Baz: offset=%d',[NativeUInt(@(PFoo3(nil)^.Baz))]));
  Memo1.Lines.Add(Format('TFoo3.Qux: offset=%d',[NativeUInt(@(PFoo3(nil)^.Qux))]));
end;
実行してみると、

TFoo3: SizeOf=19
TFoo3.Bar: offset=0
TFoo3.Baz: offset=9
TFoo3.Qux: offset=10
と、確かにその通りになっています。またDelphi 2007までは型仕様が共通のフィールド(A, B: Extended;のように複数のフィールドが","で並べられて同じ型が指定されているフィールド)は暗黙にpackedとなる、という仕様も存在していました(いま知りました)。

このようなことから、レコード型を使用するときは、packedを指定せず完全にコンパイラにお任せにして特定のメモリレイアウトを必要としないようにするか、packedを指定してパディングも明示的に配置して特定のメモリレイアウトになるようにコーディングするか、どちらかにするべきだと考えられます(この結論そのものは当たり前すぎるものですが)。

なおpackedを指定してメモリレイアウトを完全に自分で制御する場合など、定義したレコード型が正しいサイズになっているかどうかは
{$IF SizeOf(TFoo2) <> 22}
{$MESSAGE ERROR 'SizeOf(TFoo2) is not 22 bytes.'}
{$IFEND}
のようにコンパイル時にチェックすることができます(Delphi 11.2 Alexandriaだとこれを書いた瞬間にLSPで判定が行われるため、{$MESSAGE}の行がエラー表示になるか淡色表示になるかでわかりますが)。