ba1ba2ba3ba4 发表于 2023-8-21 12:09:32

分析Win10 ISO安装到iscsi时DRIVER_IRQL_NOT_LESS_OR_EQUAL蓝屏问题

本帖最后由 ba1ba2ba3ba4 于 2023-8-21 12:09 编辑

先说"Win10 ISO安装到iscsi"是怎么操作的:

1.配好iscsi server + dhcp + tftp + ipxe
2.创建一个虚拟机, 不要硬盘, 挂一个Windows ISO
2.启动虚拟机, 指定从网络启动, 注意别直接从ISO启动
3.从网络加载IPXE后, CTRL+B进入ipxe shell, 执行sanhook命令, ipxe就会在低内存区域保存iscsi盘信息;
4.sanhook成功后, 执行exit退出ipxe shell, 再按任意键, 就会从iso启动
5.如果一切正常, ISO里面的内核在初始化时, 会从低内存区域识别iscsi盘信息并尝试连接, 然后windows安装程序就能显示出磁盘, 后续就是正常安装过程了

在有光驱并且也不追求自动化批量部署的时候, 无论是硬iscsi还是软iscsi, 这都是个很标准的操作方式, 但是这两天用这个方式安装Win10 22H2时遇到奇葩蓝屏.

如图:




在走到"准备要安装的文件"这一步时必然蓝屏.


本着先怀疑自己再怀疑他人的原则, 先做了如下操作:
1. vmware换vbox
2. 配合各版本ipxe的wimboot, 在物理机上操作
3. 换不同版本的Win10 ISO
4. 检查iscsi server软坚硬是否有异常, 换不同的iscsi server及ipxe版本
5. 先本地装好系统, 然后做成IMG放到iscsi server上, 跳过iso安装过程

以上操作全都会出现一样的蓝屏. 最后找了个Win7 ISO, 在各种条件下都是一次成功.


以上操作虽然没能解决问题, 但是基本确定了问题范围: Win10.

尝试Google一下, 还真搜到一个类似的问题, 解决方法是通过挂盘修改注册表, 关闭PageFile.
然而操作之后发现依旧蓝屏. 而且看原帖也确实有人说行, 有人说不行.

那么看起来还是需要分析一下, ISO安装时蓝屏这个场景似乎不太好拿到dump文件, 所以首先对ISO开启调试设置(解压ISO -> bcdedit /store xxxxx\boot\bcd /debug on -> 重新打包ISO), 然后给虚拟机添加一个com口, 设置好windbg双机调试, 再继续先前的操作.

复现后看Windbg:
*** Fatal System Error: 0x000000d1
                     (0xFFFFF803467B59D0,0x0000000000000002,0x0000000000000008,0xFFFFF803467B59D0)

Break instruction exception - code 80000003 (first chance)

A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.

A fatal system error has occurred.

For analysis of this file, run !analyze -v
nt!DbgBreakPointWithStatus:
fffff803`3e003770 cc            int   3


解释蓝屏代码和参数0x000000d1 (0xFFFFF803467B59D0,0x0000000000000002,0x0000000000000008,0xFFFFF803467B59D0):
1. 经典代码0x000000d1, 表示IRQL太高没法处理PageFault;
2. 参数中第一个0xFFFFF803467B59D0为发生PageFault的地址;
3. 0x0000000000000002表示当时的IRQL为DISPATCH_LEVEL;
4. 0x0000000000000008表示当时是Execute操作, 不是读写;
5. 最后一个0xFFFFF803467B59D0表示是触发这次PageFault的RIP.


组合起来:
当RIP指向0xFFFFF803467B59D0准备执行时, 对应代码页不在物理内存, 发生PageFault, 但此时IRQL又是DispatchLevel, 没法做磁盘读写操作, 因此Windows选择蓝屏.


看一下栈:
0: kd> k
# Child-SP          RetAddr               Call Site
00 fffff203`25c35a78 fffff803`3e115392   nt!DbgBreakPointWithStatus
01 fffff203`25c35a80 fffff803`3e114976   nt!KiBugCheckDebugBreak+0x12
02 fffff203`25c35ae0 fffff803`3dffa2d7   nt!KeBugCheck2+0x946
03 fffff203`25c361f0 fffff803`3e00e229   nt!KeBugCheckEx+0x107
04 fffff203`25c36230 fffff803`3e009de3   nt!KiBugCheckDispatch+0x69
05 fffff203`25c36370 fffff803`467b59d0   nt!KiPageFault+0x463
06 fffff203`25c36508 fffff803`46930271   0xfffff803`467b59d0
07 fffff203`25c36510 fffff803`46923aa5   dump_NETIO!WfpReleaseFastWriteLock+0x55
08 fffff203`25c36540 fffff803`46944aac   dump_NETIO!KfdSetVisibleFilterState+0x51
09 fffff203`25c36570 fffff803`469a94cc   dump_NETIO!KfdApplyBoottimePolicy+0x58
0a fffff203`25c365d0 fffff803`3e20f25b   dump_NETIO!KfdApplyBoottimePolicyCallback+0x4c
0b fffff203`25c36600 fffff803`3e20ef6e   nt!RtlpCallQueryRegistryRoutine+0x13f
0c fffff203`25c36670 fffff803`3e2f6aae   nt!RtlpQueryRegistryValues+0x31a
0d fffff203`25c36750 fffff803`469a9363   nt!RtlQueryRegistryValuesEx+0xe
0e fffff203`25c36790 fffff803`469a92c2   dump_NETIO!KfdReadAndApplyBoottimePolicy+0x4f
.........其他略............


栈里有个小问题:
nt!KiPageFault+0x463上层是一个无符号且无模块的地址: 0xfffff803`467b59d0, 也正是蓝屏参数里的地址信息(触发以及发生PF的地址).
为啥说这里存在小问题, 因为就算微软没提供符号或者是第三方模块, 按道理windbg也应该显示为模块名+偏移而不是直接显示一个绝对地址.

这种现象常见的三个原因:
1. 栈本身就是坏的, 现在肯定不属于这种情况, 蓝屏栈相当完整和正确, 可以一直回溯到用户态;
2. Windbg开的时机比较晚, 而我确定是先开的windbg再启动的虚拟机, 也不属于这种情况;
3. 以前搞安全时候经常会收集到RCE导致的栈中出现无模块RIP的dump, 然后本次使用的ISO确实都是在网上找的种子, 还真不能保证一定是干净的.

于是检查上层调用处的代码, 也就是dump_NETIO!WfpReleaseFastWriteLock+0x55的上一行:


<pre>WfpReleaseFastWriteLock+48                                     loc_1C0010264:                        ; CODE XREF: WfpReleaseFastWriteLock+18↑j
WfpReleaseFastWriteLock+48                                                                           ; WfpReleaseFastWriteLock+42↑j
WfpReleaseFastWriteLock+48   028 48 8B 0F                      mov   rcx,                       ; Lock
WfpReleaseFastWriteLock+4B   028 48 8B D3                      mov   rdx, rbx                        ; LockState
WfpReleaseFastWriteLock+4E   028 48 FF 15 47 3E 07 00          call    cs:__imp_NdisReleaseRWLock
WfpReleaseFastWriteLock+4E
WfpReleaseFastWriteLock+55   028 0F 1F 44 00 00                nop   dword ptr
WfpReleaseFastWriteLock+5A   028 48 8B 5C 24 30                mov   rbx,
WfpReleaseFastWriteLock+5F   028 48 83 C4 20                   add   rsp, 20h
WfpReleaseFastWriteLock+63   008 5F                            pop   rdi
WfpReleaseFastWriteLock+64   000 C3                            retn
</pre>



这里可以看出发生PageFault的地址理论上应该是IAT NdisReleaseRWLock. 但因为Windbg显示不了模块, 暂时也不能确定这是不是被HOOK.
尝试手动遍历一下PsLoadedModuleList, 发现其实这个地址是在dump_ndis.sys模块内, 那看起来还是因为某些原因导致windbg没能识别出模块.

因此试试重新打开windbg, 再.reload /f重新加载符号再k, 发现windbg已经能够正确显示为dump_NDIS!NdisReleaseRWLock, 那么shellcode的可能性就可以小小的排除了:


0: kd> k
# Child-SP          RetAddr               Call Site
00 fffff203`25c35a78 fffff803`3e115392   nt!DbgBreakPointWithStatus
01 fffff203`25c35a80 fffff803`3e114976   nt!KiBugCheckDebugBreak+0x12
02 fffff203`25c35ae0 fffff803`3dffa2d7   nt!KeBugCheck2+0x946
03 fffff203`25c361f0 fffff803`3e00e229   nt!KeBugCheckEx+0x107
04 fffff203`25c36230 fffff803`3e009de3   nt!KiBugCheckDispatch+0x69
05 fffff203`25c36370 fffff803`467b59d0   nt!KiPageFault+0x463
06 fffff203`25c36508 fffff803`46930271   dump_NDIS!NdisReleaseRWLock
07 fffff203`25c36510 fffff803`46923aa5   dump_NETIO!WfpReleaseFastWriteLock+0x55
..............................


注意netio和ndis这两个模块前面都带个dump_前缀, 是因为windows dump功能初始化机制就是这样, 不是异常现象. 这里的dump_ndis.sys和dump_netio.sys在磁盘上的文件其实就是原本的ndis.sys和netio.sys, 只是内核在初始化dump功能时会加上dump_前缀, 把磁盘驱动相关的模块换个名字在内存中再次加载一份, 跟linux的kdump会重新加载一份干净的内核类似. 只有当发生蓝屏时才会走dump_前缀的模块, 目的是为了实现当系统蓝屏时, 就算是磁盘驱动自身炸了, 系统也能将DUMP数据写入磁盘(实际上iscsi启动场景这样做是没用的).

正常来说物理机应该是加载物理磁盘驱动, 但当前是iscsi启动, 对应的虚拟磁盘驱动msiscsi.sys依赖netio和ndis, 所以系统认为需要重新加载dump_ndis.sys和dump_netio.sys.

后文提到netio.sys和ndis.sys时, 如果没有特意说明, 都是指新加载的dump_ndis和dump_netio, 而不是系统正常运行时使用的ndis和netio.

到这里再重复一下:
当RIP指向NdisReleaseRWLock准备执行时, 对应代码页不在物理内存发生缺页异常然后高IRQL蓝屏.

接下来看看NdisReleaseRWLock的PTE:


0: kd> !pte @cr2
                                           VA fffff803467b59d0
PXE at FFFFFF7FBFDFEF80    PPE at FFFFFF7FBFDF0068    PDE at FFFFFF7FBE00D198    PTE at FFFFFF7C01A33DA8
contains 000000002FC09063contains 000000002FD16063contains 0A0000011FFA4863contains 950D315840B80400
pfn 2fc09   ---DA--KWEVpfn 2fd16   ---DA--KWEVpfn 11ffa4    ---DA--KWEVnot valid


最后一级not valid, 确实不在物理内存, 但这里就有点说不通:
NdisReleaseRWLock是个导出接口, 本身就可以跑在DispatchLevel, 而且它在Text段,不是Page或Init, 那么当内核加载dump_ndis模块后,只要没有外部干预(比如调用一些MM接口强行让内核把对应段变成paged), 那NdisReleaseRWLock所在的页应当是一直在物理内存才对, 不可能发生PF.

是Win的bug? 暂时还不能这么想, 比如有没有可能是因为iscsi不稳定, IO出问题而导致模块加载时出了一些异常?
这个推测与当前场景看起来确实有一定相关性(从网络iscsi启动, 并且还不是什么特别稳定的环境), 但是早就检查过iscsi server没有任何异常, 甚至换过多个不同的iscsi server.


那么还是继续看这个栈, 从最早的用户态线程起点开始看(可以跳过, 下面有TLDR版):


0: kd> k
# Child-SP          RetAddr               Call Site
00 fffff406`82900a78 fffff804`66315392   nt!DbgBreakPointWithStatus
01 fffff406`82900a80 fffff804`66314976   nt!KiBugCheckDebugBreak+0x12
02 fffff406`82900ae0 fffff804`661fa2d7   nt!KeBugCheck2+0x946
03 fffff406`829011f0 fffff804`6620e229   nt!KeBugCheckEx+0x107
04 fffff406`82901230 fffff804`66209de3   nt!KiBugCheckDispatch+0x69
05 fffff406`82901370 fffff804`6f8b59d0   nt!KiPageFault+0x463
06 fffff406`82901508 fffff804`6fa30271   dump_NDIS!NdisReleaseRWLock
07 fffff406`82901510 fffff804`6fa23aa5   dump_NETIO!WfpReleaseFastWriteLock+0x55
08 fffff406`82901540 fffff804`6fa44aac   dump_NETIO!KfdSetVisibleFilterState+0x51
09 fffff406`82901570 fffff804`6faa94cc   dump_NETIO!KfdApplyBoottimePolicy+0x58
0a fffff406`829015d0 fffff804`6640f25b   dump_NETIO!KfdApplyBoottimePolicyCallback+0x4c
0b fffff406`82901600 fffff804`6640ef6e   nt!RtlpCallQueryRegistryRoutine+0x13f
0c fffff406`82901670 fffff804`664f6aae   nt!RtlpQueryRegistryValues+0x31a
0d fffff406`82901750 fffff804`6faa9363   nt!RtlQueryRegistryValuesEx+0xe
0e fffff406`82901790 fffff804`6faa92c2   dump_NETIO!KfdReadAndApplyBoottimePolicy+0x4f
0f fffff406`82901840 fffff804`6faa93ce   dump_NETIO!KfdProcessBoottimePolicy+0x5e
10 fffff406`82901880 fffff804`6faa9413   dump_NETIO!KfdStartModuleEx+0x3e
11 fffff406`829018b0 fffff804`6faa907b   dump_NETIO!KfdStartModule+0x23
12 fffff406`829018e0 fffff804`6fab52fb   dump_NETIO!RtlInvokeStartRoutines+0x3b
13 fffff406`82901920 fffff804`6659962e   dump_NETIO!DllInitialize+0x9b
14 fffff406`82901950 fffff804`66599473   nt!MmCallDllInitialize+0x16e
15 fffff406`829019b0 fffff804`66551acc   nt!MiLoadImportDll+0x63
16 fffff406`82901a00 fffff804`66551704   nt!MiResolveImageReferences+0x214
17 fffff406`82901b10 fffff804`6655080c   nt!MiResolveImageImports+0x94
18 fffff406`82901b80 fffff804`66599454   nt!MmLoadSystemImageEx+0x690
19 fffff406`82901d20 fffff804`66551acc   nt!MiLoadImportDll+0x44
1a fffff406`82901d70 fffff804`66551704   nt!MiResolveImageReferences+0x214
1b fffff406`82901e80 fffff804`6655080c   nt!MiResolveImageImports+0x94
1c fffff406`82901ef0 fffff804`66599454   nt!MmLoadSystemImageEx+0x690
1d fffff406`82902090 fffff804`66551acc   nt!MiLoadImportDll+0x44
1e fffff406`829020e0 fffff804`66551704   nt!MiResolveImageReferences+0x214
1f fffff406`829021f0 fffff804`6655080c   nt!MiResolveImageImports+0x94
20 fffff406`82902260 fffff804`66586871   nt!MmLoadSystemImageEx+0x690
21 fffff406`82902400 fffff80b`83695cd8   nt!IopLoadCrashdmpImage+0x21
22 fffff406`82902440 fffff80b`8369513d   crashdmp!LoadPortDriver+0x488
23 fffff406`82902690 fffff80b`83694d57   crashdmp!CrashdmpLoadDumpStack+0x17d
24 fffff406`82902750 fffff804`665ab1da   crashdmp!CrashdmpInitialize+0x3c7
25 fffff406`82902870 fffff804`665ab0e6   nt!IopInitializeCrashDump+0xb2
26 fffff406`829028f0 fffff804`665aa63a   nt!IoInitializeCrashDump+0x52
27 fffff406`82902930 fffff804`665a9f4d   nt!MiCreatePagingFile+0x6de
28 fffff406`82902ac0 fffff804`6620d9f5   nt!NtCreatePagingFile+0x2d
29 fffff406`82902b00 00007ffd`280ce754   nt!KiSystemServiceCopyEnd+0x25
2a 000000ed`af8fd688 00007ffd`220b4874   ntdll!NtCreatePagingFile+0x14
2b 000000ed`af8fd690 00007ffd`229e62f5   WinSetup!CallBack_CreatePageFile+0x444
2c 000000ed`af8fd970 00007ffd`229e487f   WDSCORE!CallSubscribers+0x145
2d 000000ed`af8fda70 00007ffd`229ec39e   WDSCORE!SeqExecute+0x35f
2e 000000ed`af8fdb10 00007ffd`229ecc27   WDSCORE!WdsExecuteWorkQueue+0x62e
2f 000000ed`af8fdff0 00007ffd`220552c7   WDSCORE!WdsExecuteWorkQueueEx+0x27
30 000000ed`af8fe030 00007ff6`8db4799d   WinSetup!InstallWindows+0x33f7
31 000000ed`af8fed10 00007ff6`8db32bb6   setup!RunSetup+0x4d
32 000000ed`af8fed50 00007ff6`8db53c99   setup!wWinMain+0x966
33 000000ed`af8ffd10 00007ffd`26457614   setup!__wmainCRTStartup+0x1c9
34 000000ed`af8ffdd0 00007ffd`280826a1   KERNEL32!BaseThreadInitThunk+0x14
35 000000ed`af8ffe00 00000000`00000000   ntdll!RtlUserThreadStart+0x21

可以得到以下关键信息:

1. Windows安装进程Setup.exe调用NtCreatePagingFile创建分页文件;
2. 内核在创建分页文件时需要初始化dump机制, 即以dump_为前缀, 将原本的msiscsi.sys重新加载一份到内存;
3. 加载器在加载dump_msiscsi.sys过程中发现依赖dump_netio.sys, 于是继续加载dump_netio.sys;
4. dump_netio.sys是个内核DLL, 有DLLInitialize(类似DLLMain, 后面直接叫netio!DLLMain了), 于是内核决定调用netio!DLLMain (这里很关键, 当看到DLLInitialize时, 就隐约有了一种不好的预感)
5. netio!DLLMain需要做一些WFP BootFilter相关的初始化, 最终调用到了NdisReleaseRWLock, 发生PF -> 蓝屏.

在第4步, 看到DLLInitialize就比较怀疑可能是DLL依赖导致的问题(用户态DLLMain问题过于经典, 内核里一样会存在类似问题).

从导入表初步看一下依赖关系, 只写关键的:
1. msiscsi.sys依赖tdi/netio;
2. tdi依赖ndis;
3. netio依赖ndis;
4. ndis依赖netio;

netio.sys和ndis.sys互相依赖, 这其实也不是一个大问题, 因为windows pe loader本身就可以处理两个DLL互相依赖的情况, 只是跟用户态DLLMain一样, 在DLLMain里乱搞就容易踩坑.
会不会出问题最关键要看加载器是按什么顺序加载依赖模块, 以及DLLMain干了什么.

先看内核加载模块的逻辑, 也就是MMLoadSystemImage的实现, 跳过细节, 直接说关键点:
1. 创建文件Section/ControlArea/ProtoPTE
2. 各种初始化(比如DLL依赖,插入PsLoadedModuleList)
3. 还有一个关键步骤是把不能分页的段, 比如text段全部加载进物理内存, 在Win10上是由MiHandleDriverNonPagedSections处理(这里也正是问题的关键)
4. 看看是否有HotPatch, 如果有则加载并处理, 无则跳过(这个也比较有意思, 不过与本文无瓜, 先不管它)
5. 如果所有环节没出错, 调用MiDriverLoadSucceeded表示模块加载完成, 这里也会调用ImageNotify回调, 最后通过DBGLoadImageSymbols通知调试器有模块加载了
6. 调用DLLMain

当前问题中, netio和ndis互相依赖, netio的dllmain调用ndis的函数, 结合上面的模块加载逻辑, 什么情况才会导致0xD1蓝屏?
一种可能:
如果netio的dllmain被调用时, 内核对ndis的加载还没有走到第3步MiHandleDriverNonPagedSections, 也就是ndis的text段还没有被加载到物理内存, 那就有可能会出现高IRQL时PageFault蓝屏;
并且因为对NDIS的加载还没有完成, 那么Windbg就不会得到模块加载通知, 继而在蓝屏第一现场从内核中断到调试器时, Windbg就不能根据栈中的RetRIP找到具体模块, 最后k命令看栈就会出现最开始说的小问题: 直接显示一个无模块的绝对地址.
而执行.reload后又能显示对应模块, 则是因为.reload会主动遍历PsLoadedModuleList, 所以又能找到dump_ndis.sys.

这个推测好像完全讲得通, 但目前还没有证据.

想要实锤, 需要观察当时相关模块的加载处理顺序, 具体方式是在MMLoadSystemImage函数内的关键环节下断点, 配合简单windbg脚本打印出全过程日志:


bp nt!MmLoadSystemImageEx ".printf \"MMLoadSystemImageEx: Names(%msu - %msu - %msu)\r\n\",@rcx,@rdx,@r8;.echo \"\n\";g;"       #模块加载的起点
bp nt!MiConstructLoaderEntry ".printf \"MiConstructLoaderEntry: LDR(%p) Path:%msu\n\",@rcx,@rdx;.echo \"\n\";g;"               #这一步执行完, 在PsLoadedModuleList里就能看到对应模块了;
bp nt!MiResolveImageImports ".printf \"MiResolveImageImports: LDR(%p) Name(%msu)\n\",@rcx,@r9;.echo \"\n\";g;"               #这里开始处理IAT
bp nt!MiResolveImageReferences ".printf \"MiResolveImageReferences: LDR(%p) Name(%msu)\n\",@rcx,@rdx;.echo \"\n\";g;"
bp nt!MiSnapThunk ".printf \"MiSnapThunk: SrcBase(%p) Base(%p) pThunk(%ma)\n\",@rcx,@rdx,poi(@r8)+@rdx+0x2;.echo \"\n\";g;"    #这里是具体的IAT填充过程,可以观察从啥模块导入啥函数到啥模块;
bp nt!MiLoadImportDll ".printf \"MiLoadImportDll: Name:(%msu - %msu)\n\",@rcx,@rdx;.echo \"\n\";g;"                            #处理依赖的依赖
bp nt!MiHandleDriverNonPagedSections ".printf \"MiHandleDriverNonPagedSections: LDR(%p)\n\",@rcx;.echo \"\n\";g;"            #将不能分页的段加载进物理内存
bp nt!MmCallDllInitialize ".printf \"MmCallDllInitialize: LDR(%p)\n\",@rcx;.echo \"\n\";g;"                                    #调用DLLMain
bp nt!MiDriverLoadSucceeded ".printf \"MiDriverLoadSucceeded: LDR(%p) Name:(%msu)\n\",@rcx,@r9;.echo \"\n\";g;"                #模块加载完成

(以上并不是一次顺序执行完就结束了, 因为要处理依赖的依赖, 所以会有一定程度的递归)


使用这种方式打断点日志特别慢, 一共跑了有20分钟, 毕竟是com口调试,而且windbg还要解析执行断点后的脚本, 这个脚本引擎可能也不是十分高效.
这里就凸显出linux ftrace kprobe的优越性了, 所以喷windows, 要喷对点, 不能跟风盲目喷.
不过说实话win的这个联机调试思路还是好的, 只是历史过于悠久, 没有与时俱进, 即使用VirtualKD或者NetDebug, 会快一点, 但相比linux kprobe, 依旧慢很多.

最后整理好的断点日志(省略了不重要的MiSnapThunk), 可以不看, 下面有简化归纳:

###1. 开始加载dump_msiscsi.sys
MMLoadSystemImageEx: Names(\SystemRoot\System32\drivers\msiscsi.sys - dump_ - <Win32 error 0n30>)            
MiConstructLoaderEntry: LDR(ffffa48771a57cc0) Path:dump_msiscsi.sys
###2. 处理dump_msiscsi.sys导入表
MiResolveImageImports: LDR(ffffa487719fea70) Name(dump_msiscsi.sys)
MiResolveImageReferences: LDR(ffffa487719fea70) Name(dump_msiscsi.sys)
    ##3. 发现msiscsi.sys依赖tdi.sys, 因此加载dump_tdi.sys
    MiSnapThunk: load dump_tdi.sys
      MiLoadImportDll: Name:(\SystemRoot\System32\drivers\TDI.SYS - dump_)
        MMLoadSystemImageEx: Names(\SystemRoot\System32\drivers\TDI.SYS - dump_ - <Win32 error 0n30>)
        MiConstructLoaderEntry: LDR(ffffa48771a57530) Path:dump_TDI.SYS
        MiResolveImageImports: LDR(ffffa48771d9ada0) Name(dump_TDI.SYS)
        MiResolveImageReferences: LDR(ffffa48771d9ada0) Name(dump_TDI.SYS)
            #4. 发现tdi.sys依赖ndis.sys, 因此加载ndis.sys
            MiSnapThunk: load dump_ndis.sys
                MiLoadImportDll: Name:(\SystemRoot\System32\drivers\NDIS.SYS - dump_)
                MMLoadSystemImageEx: Names(\SystemRoot\System32\drivers\NDIS.SYS - dump_ - <Win32 error 0n30>)
                # 这里执行后PsLoadedModuleList里就有了dump_ndis.sys. 虽然此时dump_ndis.sys模块整体还没加载完成,但如果此时有其他模块依赖ndis.sys
                # 加载器还是可以从PsLoadedModuleList获取ndis.sys的eat并正常填充对应模块的IAT的
                MiConstructLoaderEntry: LDR(ffffa48771a56da0) Path:dump_NDIS.SYS
                # 开始处理dump_ndis.sys的依赖
                MiResolveImageImports: LDR(ffffa4876b4bfd70) Name(dump_NDIS.SYS)
                MiResolveImageReferences: LDR(ffffa4876b4bfd70) Name(dump_NDIS.SYS)
                  ###5. 发现ndis.sys依赖netio.sys, 因此加载netio.sys
                  MiSnapThunk: load dump_netio.sys
                        MiLoadImportDll: Name:(\SystemRoot\System32\drivers\NETIO.SYS - dump_)
                        MMLoadSystemImageEx: Names(\SystemRoot\System32\drivers\NETIO.SYS - dump_ - <Win32 error 0n30>)
                        MiConstructLoaderEntry: LDR(ffffa48771a57cc0) Path:dump_NETIO.SYS
                        MiResolveImageImports: LDR(dump_netio) Name(dump_NETIO.SYS)
                        MiResolveImageReferences: LDR(dump_netio) Name(dump_NETIO.SYS)
                            ##6. 发现netio.sys依赖ndis.sys, 此时dump_ndis.sys已经进了PsLoadedModuleList, 因此这里不需要再从头加载dump_ndis.sys
                            ##而是找到dump_ndis.sys的base+eat, 填充netio.sys的iat
                            MiSanpThunk: import from dump_ndis.sys                       
                        ###到这里, netio.sys的所有IAT处理完成, 使netio.sys的text/data等段加载到物理内存常驻
                        MiHandleDriverNonPagedSections: LDR(dump_netio)
                        MiDriverLoadSucceeded: LDR(dump_netio) Name:(\SystemRoot\System32\drivers\dump_NETIO.SYS)
                        ##7. 调用netio!DLLMain
                        MmCallDllInitialize: LDR(dump_netio)
                                ##netio!DLLMain调用NDISReleaseRWLock
                              ##看看前面的模块依赖处理过程, 内核加载器对dump_NDSI.SYS的加载, 刚刚执行到从netio.sys导入API的阶段(对应上面#4和#5之间)
                              ##还没有执行到MiHandleDriverNonPagedSections, 那么NDIS.SYS的text/data此时也就没有常驻物理内存
                                ##这个时候netio!DLLMain去调用ndis.sys!NDISReleaseRWLock, 自然会发生pagefault, 而此时IRQL又是DPCLEVEL, 系统没法处理这次PF, 只能选择蓝屏


以上是手动整理的加载过程全过程日志, 其实我本地排版是很好的, 但是dz这个编辑器太难用, 估计排版应该是乱了, 简单描述一下:

1. 调用MMLoadSystemImage加载dump_msiscsi.sys
2. 处理dump_msiscsi.sys导入表
3. 发现msiscsi.sys依赖tdi.sys, 因此加载dump_tdi.sys
4. 发现tdi.sys依赖ndis.sys, 加载dump_ndis.sys
5. 发现ndis.sys依赖netio.sys, 加载dump_netio.sys
6. 发现netio.sys依赖ndis.sys, 此时dump_ndis.sys在第4步已经进了PsLoadedModuleList, 因此这里不需要再从头加载dump_ndis.sys, 而是直接从已加载的dump_ndis.sys的Base+EAT, 填充netio.sys的IAT;
7. netio.sys的所有依赖处理完毕, 调用netio!DLLMain
8. netio!DLLMain调用dump_ndis!NDISReleaseRWLock

蓝屏在第8步发生, 因为这个时候netio.sys的IAT确实处理完成, 仅看netio自身, 确实已经具备了执行条件, 但是它所依赖的NDIS还处于填充IAT阶段, 根本就没走到MiHandleDriverNonPagedSections这一步, 那么dump_ndis.sys的text段此时就没有被完全加载进物理内存. 而这个时候又是DPC_LEVEl, 发生PF必然蓝屏.


按照以上分析, 可以知道当netio.sys和ndis.sys互相依赖,并且netio!DLLMain会调用NDIS接口, 那么在处理DLL依赖时:
1.如果先加载NDIS.SYS, 然后再加载NDIS.SYS的依赖项NETIO.SYS, netio!DLLMain就会引发PageFault蓝屏;
2.如果先加载Netio.sys, 然后再加载Netio.sys的依赖项NDIS.SYS, Netio!DLLMain就不会引发PageFault蓝屏;

如果理解了上面的分析, 那么应该会有一个疑问:
为了dump初始化而加载的dump_ndis和dump_netio, 和系统正常运行时所使用的ndis和netio是完全相同的文件, 那为什么在系统正常启动, 加载正常的ndis和netio时不会遇到这个问题? 按道理文件一样, IAT一样,依赖顺序处理也一样, 都是先加载ndis, netio作为依赖库后加载但先执行DLLInitialize, 理论上也会遇到上面的PageFault蓝屏问题才对.

原因:
系统的正常ndis.sys在Services注册表中Start值是0(BootStart),而所有BootStart驱动,都是在引导阶段由winload.exe/.efi去加载,直接读到并常驻物理内存,并不走MMLoadSystemImage这套流程, 在winload.exe加载完必要组件, 跳到内核的KiSystemStartup入口时, ndis的text段已经全部在物理内存了, 所以netio!DLLInitialize调用NDIS模块的函数就不会发生PageFault.

我觉得应该就是这个原因. 但在分析三方软件问题时, 静态也好,动态调试也好, 整体来说还是一个偏黑盒的方式, 大多数时候只对关键位置调试跟踪, 一般不会对整个二进制文件的所有位置逐一分析, 稍微偏大的软件, 本身分散的模块化构成, 逻辑错综复杂, 在有限的时间和人力成本条件下, 很难对整个二进制文件做全面覆盖, 即使借助自动化也很难100%.最后就可能发生在A环境和B环境结论不一致的情况. 一个比较有用且比人肉分析省时间的做法是多做验证, 在不同的场景下去验证结论.

比如上面说的"正常加载时, NDIS.sys是boot start,由WinLoad.exe加载, 所以不会蓝屏", 这个结论真的对吗?

很难保证100%, 所以发帖前我实际验证了一次:
在一台正常启动的本地盘机器上, 将NDIS.SYS的Start由0(BootStart)改为3(DemandStart)(避免被Winload.exe加载), 然后重启机器观察是否能复现出来, 预期应当能复现.

结果是会蓝屏, 但蓝屏原因并不是之前所说的DLL依赖产生的PageFault, 具体什么原因不重要, 最关键是当时Netio!DllMain已经成功执行,并没有产生相同的PF蓝屏.

这跟前面的结论相悖.

具体问题具体分析, 这个现象实际是因为SYS不仅仅只是正常的带有DriverEntry的内核模块, 同时也可以是动态库, 如果将NDIS.SYS启动方式改为3, 但因为TCPIP.SYS也导入了NDIS, 而TCPIP.SYS的Start是0, 那么WinLoad.exe在加载TCPIP.SYS时, 就会忽略NDIS.SYS的启动方式, 继续在引导阶段就把NDIS.SYS作为依赖项加载到物理内存, 所以就没法复现出netio!DLLMain PF蓝屏问题;

既然如此, 如果把TCPIP.SYS也改为3, 是不是就能复现了:

实际还是不能复现, 虽然最后也会蓝屏, 但蓝屏原因同样不是Netio!DLLMain调用NDIS接口触发PF导致.

继续用前面提到的几个断点去抓日志看加载流程:

#加载tdx.sys
MMLoadSystemImageEx: Names(\SystemRoot\system32\DRIVERS\tdx.sys - <Win32 error 0n30> - <Win32 error 0n30>)
        MiConstructLoaderEntry: LDR(ffff9b8ac55361c0) Path:tdx.sys
        MiResolveImageImports: LDR(ffff9b8ac4e34570) Name(tdx.sys)
        MiResolveImageReferences: LDR(ffff9b8ac4e34570) Name(tdx.sys)
                #tdx.sys依赖netio.sys
                MiLoadImportDll: Name:(\SystemRoot\system32\DRIVERS\NETIO.SYS - <Win32 error 0n30>)
                MMLoadSystemImageEx: Names(\SystemRoot\system32\DRIVERS\NETIO.SYS - <Win32 error 0n30> - <Win32 error 0n30>)
                MiConstructLoaderEntry: LDR(ffff9b8ac55370e0) Path:NETIO.SYS
                MiResolveImageImports: LDR(ffff9b8ac6e1b520) Name(NETIO.SYS)
                MiResolveImageReferences: LDR(ffff9b8ac6e1b520) Name(NETIO.SYS)
                        #netio.sys依赖ndis.sys
                        MiLoadImportDll: Name:(\SystemRoot\system32\DRIVERS\NDIS.SYS - <Win32 error 0n30>)
                        #加载ndis.sys
                        MMLoadSystemImageEx: Names(\SystemRoot\system32\DRIVERS\NDIS.SYS - <Win32 error 0n30> - <Win32 error 0n30>)
                        MiConstructLoaderEntry: LDR(ffff9b8ac5536ed0) Path:NDIS.SYS
                        #填充ndis iat
                        MiResolveImageImports: LDR(ffff9b8ac4a84790) Name(NDIS.SYS)
                        MiResolveImageReferences: LDR(ffff9b8ac4a84790) Name(NDIS.SYS)
                        #iat 填充完成
                        MiHandleDriverNonPagedSections: LDR(ffff9b8ac4a84790)
                        #ndis.sys加载完成, 此时ndis text/data段已经全部在物理内存
                        MiDriverLoadSucceeded: LDR(ffff9b8ac4a84790) Name:(\SystemRoot\system32\DRIVERS\NDIS.SYS)
                        MmCallDllInitialize: LDR(ffff9b8ac4a84790)
                #netio.sys加载成功
                MiHandleDriverNonPagedSections: LDR(ffff9b8ac6e1b520)
                MiDriverLoadSucceeded: LDR(ffff9b8ac6e1b520) Name:(\SystemRoot\system32\DRIVERS\NETIO.SYS)
                #netio!DLLMain
                MmCallDllInitialize: LDR(ffff9b8ac6e1b520)



加载顺序是这样的:

tdx.sys -> netio.sys -> ndis.sys -> netio!DLLMain

加载TDX.sys时发现依赖netio.sys, 因此加载netio.sys, 而netio.sys又依赖NDIS.SYS, 因此加载NDIS.SYS, 并且顺利完成, 执行到了MiHandleDriverNonPagedSections, 最后MiDriverLoadSucceeded, 那么这种情况下NDIS.SYS text段就已经全部加载进了内存, 最后回到netio.sys, 执行netio!DLLMain, 此时自然就不会发生PageFault蓝屏问题. 这种就是netio先加载, ndis后加载, 理论就是没问题.

虽然没有复现出预期的PageFault现象, 但通过对这个场景相关现象的分析, 其实佐证了前面netio/ndis之间DLL依赖处理顺序的结论. 并不是一件坏事.

同时也知道了要怎么才能在系统正常加载netio/ndis时也复现出PF蓝屏:
将所有可能间接导入netio/ndis的模块全部配置为3, 将ndis.sys配置为1, 这样ndis.sys不会因为被其他模块依赖而被间接加载, 同时也可以确保netio.sys是作为NDIS.SYS的依赖项后加载, 以及确保netio.sys!DLLMain先执行.

如此操作后顺利复现:

MMLoadSystemImageEx: Names(\SystemRoot\system32\drivers\ndis.sys - <Win32 error 0n30> - <Win32 error 0n30>)
MiConstructLoaderEntry: LDR(ffffad0f46dac2f0) Path:ndis.sys
MiResolveImageImports: LDR(ffffad0f46884790) Name(ndis.sys)
MiResolveImageReferences: LDR(ffffad0f46884790) Name(ndis.sys)
MiLoadImportDll: Name:(\SystemRoot\system32\drivers\NETIO.SYS - <Win32 error 0n30>)
MMLoadSystemImageEx: Names(\SystemRoot\system32\drivers\NETIO.SYS - <Win32 error 0n30> - <Win32 error 0n30>)
MiConstructLoaderEntry: LDR(ffffad0f46dac0e0) Path:NETIO.SYS
MiResolveImageImports: LDR(ffffad0f48c1b8a0) Name(NETIO.SYS)
MiResolveImageReferences: LDR(ffffad0f48c1b8a0) Name(NETIO.SYS)
MiHandleDriverNonPagedSections: LDR(ffffad0f48c1b8a0)
MiDriverLoadSucceeded: LDR(ffffad0f48c1b8a0) Name:(\SystemRoot\system32\drivers\NETIO.SYS)
MmCallDllInitialize: LDR(ffffad0f48c1b8a0)
KDTARGET: Refreshing KD connection

*** Fatal System Error: 0x000000d1
                     (0xFFFFF807553E59D0,0x0000000000000002,0x0000000000000008,0xFFFFF807553E59D0)

Break instruction exception - code 80000003 (first chance)

A fatal system error has occurred.
Debugger entered on first try; Bugcheck callbacks have not been invoked.

A fatal system error has occurred.

For analysis of this file, run !analyze -v
nt!DbgBreakPointWithStatus:
fffff807`4f403770 cc            int   3
0: kd> k
# Child-SP          RetAddr               Call Site
00 ffffe809`e2205658 fffff807`4f515392   nt!DbgBreakPointWithStatus
01 ffffe809`e2205660 fffff807`4f514976   nt!KiBugCheckDebugBreak+0x12
02 ffffe809`e22056c0 fffff807`4f3fa2d7   nt!KeBugCheck2+0x946
03 ffffe809`e2205dd0 fffff807`4f40e229   nt!KeBugCheckEx+0x107
04 ffffe809`e2205e10 fffff807`4f409de3   nt!KiBugCheckDispatch+0x69
05 ffffe809`e2205f50 fffff807`553e59d0   nt!KiPageFault+0x463
06 ffffe809`e22060e8 fffff807`55560276   0xfffff807`553e59d0
07 ffffe809`e22060f0 fffff807`55553aa5   NETIO!WfpReleaseFastWriteLock+0x5a
08 ffffe809`e2206120 fffff807`55574aac   NETIO!KfdSetVisibleFilterState+0x51
09 ffffe809`e2206150 fffff807`555d94cc   NETIO!KfdApplyBoottimePolicy+0x58
0a ffffe809`e22061b0 fffff807`4f60f25b   NETIO!KfdApplyBoottimePolicyCallback+0x4c
0b ffffe809`e22061e0 fffff807`4f60ef6e   nt!RtlpCallQueryRegistryRoutine+0x13f
0c ffffe809`e2206250 fffff807`4f6f6aae   nt!RtlpQueryRegistryValues+0x31a
0d ffffe809`e2206330 fffff807`555d9368   nt!RtlQueryRegistryValuesEx+0xe
0e ffffe809`e2206370 fffff807`555d92c2   NETIO!KfdReadAndApplyBoottimePolicy+0x54
0f ffffe809`e2206420 fffff807`555d93ce   NETIO!KfdProcessBoottimePolicy+0x5e
10 ffffe809`e2206460 fffff807`555d9413   NETIO!KfdStartModuleEx+0x3e
11 ffffe809`e2206490 fffff807`555d907b   NETIO!KfdStartModule+0x23
12 ffffe809`e22064c0 fffff807`555e52fb   NETIO!RtlInvokeStartRoutines+0x3b
13 ffffe809`e2206500 fffff807`4f79962e   NETIO!DllInitialize+0x9b
14 ffffe809`e2206530 fffff807`4f799473   nt!MmCallDllInitialize+0x16e
15 ffffe809`e2206590 fffff807`4f751acc   nt!MiLoadImportDll+0x63
16 ffffe809`e22065e0 fffff807`4f751704   nt!MiResolveImageReferences+0x214
17 ffffe809`e22066f0 fffff807`4f75080c   nt!MiResolveImageImports+0x94
18 ffffe809`e2206760 fffff807`4f750166   nt!MmLoadSystemImageEx+0x690
19 ffffe809`e2206900 fffff807`4f7330b4   nt!MmLoadSystemImage+0x26
1a ffffe809`e2206940 fffff807`4fa52b87   nt!IopLoadDriver+0x23c
1b ffffe809`e2206b10 fffff807`4fa6331a   nt!IopInitializeSystemDrivers+0x157
1c ffffe809`e2206bb0 fffff807`4f7a881b   nt!IoInitSystem+0x2e
1d ffffe809`e2206be0 fffff807`4f3030e5   nt!Phase1Initialization+0x3b
1e ffffe809`e2206c10 fffff807`4f402e08   nt!PspSystemThreadStartup+0x55
1f ffffe809`e2206c60 00000000`00000000   nt!KiStartSystemThread+0x28

注意这个栈的起点是内核正常初始化(非iscsi启动, 非dump功能初始化), 因为NDIS.SYS start值为1, 同时没有其他模块依赖NDIS.SYS, 内核就正常加载NDIS.SYS, 解析导入加载NETIO.SYS, 然后在NETIO.SYS!DLLMain触发跟iscsi启动场景一模一样的PageFault蓝屏.
这种就是NDIS先加载, NETIO后加载. 必然蓝屏.

至此印证前面的所有结论.


一些花絮:

蓝屏时DLLMain在调用NDISReleaseRWLock, 说明之前必然也有调用NDISAcquireRWLock, 那在调用NDISAcquireRWLock的时候为啥没蓝屏:

因为虽然也是在netio!DLLMain的上下文, 但当时最开始的IRQL其实是passive_level, 发生PF可以正常处理. 是NDISAcquireRWLock被调用后在内部将IRQL提升为DPCLEVEL, 直到ReleaseRWLock被调用才会降回去.

通过查看AcquireRWLock的PTE可以确认AcquireRWLock当时确实是在物理内存:

0: kd> !pte dump_ndis!NdisAcquireRWLockWrite
                                           VA fffff8046f8b42f0
PXE at FFFFFC7E3F1F8F80    PPE at FFFFFC7E3F1F0088    PDE at FFFFFC7E3E011BE0    PTE at FFFFFC7C0237C5A0
contains 000000002FC09063contains 000000002FC0A063contains 0A00000118278863contains 00000001252DC021
pfn 2fc09   ---DA--KWEVpfn 2fc0a   ---DA--KWEVpfn 118278    ---DA--KWEVpfn 1252dc    ----A--KREV

然后顺便看下NdisAcquireRWLockWrite和NdisReleaseRWLock各自的VA:
NdisAcquireRWLockWrite: fffff804`6f8b42f0
NdisReleaseRWLock:      fffff804`6f8b59d0

这两函数距离很紧, 但又不够紧(不在一个页), 如果它俩恰巧在1个页, 那么在netio调用NdisAcquireRWLockWrite发生PF的时候, 内核也会顺便把NdisReleaseRWLock也带进物理内存, 也就不会发生蓝屏了(当然前提是netio.sys!dllmain没有再调用其他dump_ndis.sys内的函数).
不过这样的话, 这个bug就更难被发现. 也不应该是靠这种方式去解决.


Win7为什么没问题?
观察Win7的netio.sys/ndis.sys的导入表, 他们也是互相依赖的, 并且netio!DLLMain中一样有调用NDIS接口的逻辑, 那为啥Win7上就不会蓝屏:
Win7和Win10的dump初始化机制有差异, 当Win7从iscsi网络启动时, 内核dump机制会初始化失败, 根本就不会走到加载dump_miscsi.sys这一步, 也就不会有后续的DLL互相依赖问题.
也正是因为内核dump机制初始化失败, 当Win7走原生iscsi功能从网络启动时, 系统内必然会出现一条"系统未能初始化故障转储程序"日志:




作为用户, 要解决这个问题当前只能是靠一些临时规避方案, 这里直接给方案, 不谈个中细节:
1.删除Services\BFE\Parameters\Boot\Filter下的注册表; 如果注册表不存在, netio!DLLMain就不会执行到后面触发蓝屏的流程; 但是要注意这个注册表有可能会被BFE或其他组件重新创建出来, 需要禁用其他服务, 不太推荐;
2.关闭系统的蓝屏dump功能, 关闭后当内核创建分页文件的时候, 就不会去初始化dump机制, 也就不会加载dump_msiscsi.sys, 最后就不会调用到dump_netio.sys!dllmain; (关闭dump功能不影响分页文件)

至于禁用分页文件, 其实确实可以作为一个解决方案, 但是它有局限性, 只能用于一种场景:
先本地装系统, 关闭分页文件, 然后disk2vhd生成img.
这个方案不能用于直接从pxe->iso启动setup.exe然后安装到iscsi盘的场景(至少对我使用的三个ISO不行), 因为ISO里的安装程序setup.exe逻辑是直接调用NtCreatePagingFile强行创建分页文件, 完全不理会注册表中的分页文件开关.

而这应当也是在我最开始搜到类似问题, 按同样操作关闭页面文件没有效果的原因, 甚至也可能是有些人说行, 有些人说不行的原因:
说行的可能是disk2vhd这种方式部署(或者是他们用的ISO里面的setup.exe不会像我这个版本这样强行创建分页文件), 说不行的应该跟我一样是直接从ISO启动setup.exe安装;

本质不是PageFile原因, 而是在创建PageFile的时候, 如果DUMP功能是开着的, 内核就会顺便初始化DUMP, 从而触发最根源的DLL依赖加载顺序问题.
关掉PageFile, 或者禁用dump功能, 都可以规避.
然后Windows只会在启动卷上初始化DUMP功能, 比如如果在本地盘启动的系统上, 挂载一个网络iscsi盘作为数据盘, 然后把页面文件设置在这个iscsi数据盘, 这种情况下应当不会蓝屏(未验证).

再扩展一下:
在windows原生iscsi网络启动场景, 系统发生蓝屏时, 即使蓝屏dump保存功能是开启的, 也不大可能成功写入蓝屏dump信息, 因为这个场景下, 系统往磁盘写入dump数据时, 无论怎么写都得经过msiscsi.sys, 而msiscsi.sys是通过WSK Socket接口走系统的标准协议栈跟iscsi server通信, 在蓝屏时, 系统整体环境已经不正常, msiscsi.sys此时并不能稳定跟iscsi server通信. 虽然系统重新加载了一份netio和ndis, 但是还有网卡驱动以及tcpip.sys还是用的原来的, 要想蓝屏时依旧稳定收发, 整条IO链上所有模块都要配合才行, 这也是windows做的不如Linux的地方, linux是直接拉起一个新内核, 写dump时所处环境相对要更稳一些.

实际只有物理磁盘驱动, 或者自带TCPIP协议栈不依赖OS的硬iscsi, 才能实现稳定的蓝屏dump保存功能.
软iscsi如果只想保存几十到几百K的minidump还是可以的,比如通过NDIS协议驱动直接构造UDP包发出去, 有比较不错的概率可以在系统彻底死掉前把数据发出去, 但要想稳定保存上G甚至10G+的完整dump就比较困难, 大多数时候都是发着发着整个系统就彻底死掉(也不是绝对不行, 实现起来有些复杂, 又没有太大价值).


最后, 根源的DLL依赖问题得微软自己去修(如果我没漏掉什么的话). 另外此问题理论上在iscsi启动场景必现, 至少在我使用的22H2以及其他相近的几个版本上必现, 感觉微软只测了本地盘启动, 根本没测过iscsi启动场景, 略离谱.

美俪女神 发表于 2023-8-22 11:08:19

我最头痛的事情之一就是分析蓝屏,哪怕是分析自己驱动的蓝屏都心烦。

楼主真乃高人也,把微软的蓝屏都给分析得这么细致。

自从阿三主政微软,WINDOWS的毛病就越来越多了,界面上甚至还有英语单词的拼写错误。

ba1ba2ba3ba4 发表于 2023-8-24 00:26:36

美俪女神 发表于 2023-8-22 11:08
我最头痛的事情之一就是分析蓝屏,哪怕是分析自己驱动的蓝屏都心烦。

楼主真乃高人也,把微软的蓝屏都给分 ...

确实阿三之后, Win有些地方做得有点马虎.

AyalaRs 发表于 2023-9-3 20:44:22

之前用mdt搞过pxe启动win10 企业版rtm测试成功过,mdt这玩意可以帮你集成兼容pxe启动的驱动
页: [1]
查看完整版本: 分析Win10 ISO安装到iscsi时DRIVER_IRQL_NOT_LESS_OR_EQUAL蓝屏问题