记一次C#平台调用中因非托管union类型导致的内存访问越界

离奇现象

大家在C#代码中遇到这样的问题吗:一个局部变量,上一秒还是非null的,下一秒就变成null了,中间只调用了一个非托管函数。

我前几天就遇到了这样的问题,问题代码长这样:

复制代码
private static PropVariant GetProperty(Window window, PropertyKey key)
{
    var hwnd = new WindowInteropHelper(window).EnsureHandle();
    Win32.Shell32.SHGetPropertyStoreForWindow(hwnd, out IPropertyStore ps);
    ps.GetValue(ref key, out PropVariant value);
    return value;
}

ps是一个COM类型的对象,本来是非null的,调用完它的GetValue方法后,就变成null了,离奇地是,没有任何报错,调用返回值是正常的,value的结果也是正确的。

在这里,IPropertyStore是一个Com类型,PropVariant类型的原型是这样的,具体可看官方文档:PROPVARIANT 结构 (propidl.h)

复制代码
typedef struct tagPROPVARIANT {
  union {
    typedef struct {
      VARTYPE      vt;
      PROPVAR_PAD1 wReserved1;
      PROPVAR_PAD2 wReserved2;
      PROPVAR_PAD3 wReserved3;
      union {
        //....此处省略
      };
    } tag_inner_PROPVARIANT, PROPVARIANT, *LPPROPVARIANT;
    DECIMAL decVal;
  };
} PROPVARIANT, *LPPROPVARIANT;

然后我是这样封装的

复制代码
[StructLayout(LayoutKind.Sequential)]
internal record struct PropVariant(ushort vt, IntPtr pointer);

暗藏玄机

由于我对平台调用的机制不是特别熟悉,所以一开始想到的是,是不是因为我封装的结构体不对,导致平台调用的内部发生了什么异常。但是仔细一想感觉也不太可能,非托管代码里面不管发生了什么,应该都不会直接影响到我托管代码里的变量的值吧。

但是我觉得大概率还是PropVariant封装的有问题,所以我就尝试了一下把PropVariant的大小申明为128字节,再试了一下,果然就没问题了。

复制代码
[StructLayout(LayoutKind.Sequential, Size = 128)]
internal record struct PropVariant(ushort vt, IntPtr pointer);

这个封装在64位应用里面默认大小是16字节,我改成显式申明为128字节之后就没问题了,那么说明平台调用对PropVariant的操作实际上是大于16字节的,因为访问越界把ps变量的值给写掉了。不过问题是,这个越界是怎么越到ps这个变量上去的。

因为搞不懂到底非托管代码是怎么写到我的ps变量的,于是我就不断尝试变量申明的方式,试图避免非托管代码去改写我的ps变量。终于在尝试到下面这种写法的时候,让我发现了一点端倪:

复制代码
    IPropertyStore ps;
    var hwnd = new WindowInteropHelper(window).EnsureHandle();
    Win32.Shell32.SHGetPropertyStoreForWindow(hwnd, out ps);
    ps.GetValue(ref key, out PropVariant value);
    return value;

现在,被改写的变量从ps变成了hwnd,那么很容易可以联想到,局部变量是按照某种顺序排列在一起,当操作PropVariant越界的时候,自然就写到了它后面的变量。现在这种写法,大概率是改变了局部变量的排序,所以被改写的变量从ps变成了hwnd。

于是,我拿出了我几个月前刚学的的阅读IL代码的技能。GetValue这一行的IL代码是这样的:

复制代码
    IL_0016: ldloc.0      // ps
    IL_0017: ldarga.s     key
    IL_0019: ldloca.s     'value'
    IL_001b: callvirt     instance int32 IPropertyStore::GetValue(valuetype PropertyKey&, valuetype PropVariant&)
    IL_0020: pop

ldloca.s指令把'value'这个变量的地址放入到计算堆栈中,然后调用了GetValue方法,于是平台调用在往'value'这个变量的地址写数据的时候,就越界到其他变量去了。

那'value'变量是存在哪里的呢,我搜索了一下,搜到了一个叫局部变量表(Record Frame)的东西,它在IL代码里长这样:

复制代码
    .locals init (
      [0] class IPropertyStore ps,
      [1] native int hwnd,
      [2] valuetype PropVariant 'value',
      [3] valuetype PropVariant V_3
    )

然后,在我的原始代码里,它长这样:

复制代码
    .locals init (
      [0] native int hwnd,
      [1] class IPropertyStore ps,
      [2] valuetype PropVariant 'value',
      [3] valuetype PropVariant V_3
    )

可以看到,hwnd和ps交换了位置。那么这个局部变量表应该就是以0~3这样的顺序入栈的,所以当写'value'变量越界的时候,就写到了它后面的hwnd或者ps的数据。

罪魁祸首

看到这里,几乎就可以肯定问题是PropVariant封装的大小不对了。PropVariant的原型还挺复杂的,套了几层的union。我为了图方便,让ai给我写了个例子,所以这个封装方式其实是ai帮我写的(小小ai,速来背锅),在调用IPropertyStore.SetValue方法的时候倒是没发现什么异常,没想到在这里给我埋了一个坑。

于是我用Win32Cs这个包生成了一个PropVariant的封装,首先确认了一下Win32Cs给我生成的封装调用是正常的,然后调用Marshal.SizeOf<PROPVARIANT>();看了一下它封装的大小,发现实际上是24字节。

这时候死去的C语言记忆突然开始攻击我:我们都知道,union类型的大小是它占用内存最大的成员的大小(还有考虑内存对齐)。让我们再来看看PropVariant的原型:

复制代码
typedef struct tagPROPVARIANT {
  union {
    typedef struct {
      VARTYPE      vt;
      PROPVAR_PAD1 wReserved1;
      PROPVAR_PAD2 wReserved2;
      PROPVAR_PAD3 wReserved3;
      union {
        //....此处省略
      };
    } tag_inner_PROPVARIANT, PROPVARIANT, *LPPROPVARIANT;
    DECIMAL decVal;
  };
} PROPVARIANT, *LPPROPVARIANT;

中间有个union的成员我省略掉了,为什么省略掉,因为太多了......于是我让ai帮我封装,它给我把union直接封装成了IntPtr,我一想也挺合理的,拿到IntPtr,想要什么类型的值再自己转换嘛,完全没有注意到这一长串的成员里面有一些长度超过了8个字节的结构体(关键也是我完全忘了union类型的大小了)。

这个union里面的某些成员的类型,大小是16个字节的,例如这个结构体类型,它是一个uint加一个指针:

复制代码
[global::System.CodeDom.Compiler.GeneratedCode("Microsoft.Windows.CsWin32", "0.3.183+73e6125f79.RR")]
internal partial struct CALPWSTR
{
    internal uint cElems;

    internal unsafe winmdroot.Foundation.PWSTR* pElems;
}

所以这个union的大小是16个字节,然后加上前面的8个字节,tag_inner_PROPVARIANT这个结构体的大小是24字节。外层的union中,DECIMAL在文档中说明是和tag_inner_PROPVARIANT具有相同的大小,所以,整个结构体的大小是24字节。

最后,我的代码就改成这样了:

复制代码
[StructLayout(LayoutKind.Sequential, Size = 24)]
internal record struct PropVariant(ushort vt, IntPtr pointer);

没错,只要显式申明封装大小是24字节就可以了,因为那个成员巨多的union里面,我只用到了string和bool,所以就不做复杂的封装了。

相关推荐
huluang8 小时前
Word XML 批注范围克隆处理器
开发语言·c#
蓝点lilac11 小时前
C# WPF 内置解码器实现 GIF 动图控件
c#·.net·wpf·图像
唐青枫13 小时前
别再用 Thread 了!掌握 C#.NET Task 异步编程的正确打开方式
c#·.net
月巴月巴白勺合鸟月半14 小时前
一个C#的段子
开发语言·c#
筱璦1 天前
最新完整内、外期货量化交易系统C#源码可售
c#
xingkongvv121 天前
Linq Union和Concat
c#·linq
钢铁男儿1 天前
C# 异步编程:提升程序性能与用户体验的利器
c#·php·ux
曹牧1 天前
C#:dnSpy
开发语言·c#
LZQqqqqo1 天前
WinForm 中 ListView 控件的实战应用与功能拓展
开发语言·microsoft·c#·winform