Anti-Anti-Dump_and_No...(二)

标 题: [翻译]Anti-Anti-Dump_and_No...(下)
作 者: aalloverred
时 间: 2006-05-14,18:34
链 接: http://bbs.pediy.com/showthread.php?threadid=25564

[翻译]Anti-Anti-Dump_and_Nonintrusive_tracers(下)

[出处及相关]
kanxue的ARTeam团队优秀文章汇总帖:
http://bbs1.pediy.com:8081/showthread.php?s=&threadid=24875
原文地址:
http://bbs1.pediy.com:8081/attachment.php?s=&attachmentid=1236
上半部分地址(由kkbing翻译):
http://bbs1.pediy.com:8081/showthread.php?s=&threadid=25001
[重要的废话]
实在不好意思贴出来,因为这篇文章着实的超出了我的能力,我只是一只小小小小鸟
因此大家就当帮忙找错误看好了,谢谢!只希望千万不要误人子弟就好了:)

最后的附件是做成了pdf格式的译文,因为我是按照原文的格式翻译的,贴出来的可能看着不舒服.
[译者]aalloverred

[译文]


4. 用于内存管理器的非侵入式跟踪器
一旦我们利用代码强制性的将所有的东西存到了一个可以转存的地方,我们就可以准备访问这些地方了。一些保护壳会在分配的缓存中存储多态的oep,所以我们还要将eip访问权交给这些缓存。有很多方法实现这一点,我这里要使用的是
PAGE_GUARD, 和我在文献[6]中的oepfinder X.Y.Z中介绍的方法一样。
4.1. 编写非侵入式跟踪器
有关非侵入式跟踪器,有很多文章中都有所涉及,而在文献[5,6]中讲解的尤其得多。但我还是要提一些比较重要的东西;不久以后,也就是你读完了这一部分以后,你就会体会到理解这些东西是多么的重要以及它们是多么的有用。我在[5,6]中已经讲述过非侵入式跟踪器和loaders了,所以这里我将只是简要的提及它们。
非侵入式跟踪器的相关概念包括KiUserExceptionDispatcher的hooking,及其我们自己处理所有的异常。如果我们处理异常,我们只是简单的调用NtContinue ;如果我们不处理异常,我们就返回KiUserExceptionDispatcher。我们先来看一个非侵入式跟踪器样板示例:
nonintrusive: mov ecx, [esp+4]
mov ebx, [esp]
pushad
call deltakiuser
deltakiuser:  pop ebp
sub ebp, offset deltakiuser
...
retkiuser0:   popad
mov [ecx.context_dr0], 0
retkiuser:    push 0deadc0deh
ret
不要因为看到了mov [ecx.context_dr0], 0这一句而困惑;当讲述道第4.3节的时候你自然就会理解它。
现在我们先看一看KiUserExceptionDispatcher这个函数:
.text:7C90EAEC mov ecx, [esp+arg_0]
.text:7C90EAF0 mov ebx, [esp+0]
.text:7C90EAF3 push ecx
.text:7C90EAF4 push ebx
.text:7C90EAF5 call _RtlDispatchException@8
.text:7C90EAFA or al, al
.text:7C90EAFC jz short loc_7C90EB0A
.text:7C90EAFE pop ebx
.text:7C90EAFF pop ecx
.text:7C90EB00 push 0
.text:7C90EB02 push ecx
.text:7C90EB03 call _ZwContinue@8
.text:7C90EB08 jmp short loc_7C90EB15
.text:7C90EB0A
.text:7C90EB0A loc_7C90EB0A:
.text:7C90EB0A pop ebx
.text:7C90EB0B pop ecx
.text:7C90EB0C push 0
.text:7C90EB0E push ecx
.text:7C90EB0F push ebx
.text:7C90EB10 call _ZwRaiseException@12
你可能注意到了这与我在[6]中所讲述的东西不是一样的么?别急,接着往下走,你会看到为什么会这样。我们将要hook KiUserExceptionDispatcher的前两条指令,注意如果我们要得到stealth code我们可以hook _RtlDispatchException并将我们的跟踪器插入到那里。有无数种可能。我们hook KiUserExceptionDispatcher 时必须模仿被覆盖的字节它们是:
.text:7C90EAEC mov ecx, [esp+4]
.text:7C90EAF0 mov ebx, [esp]
ecx = 指向CONTEXT
ebx = 指向EXCEPTION_CODE
我们可以通过检查的ebx的值轻松的决定我们是要处理这个异常还是要将控制权返回给KiUserExceptionDispatcher:
nonintrusive: mov ecx, [esp+4]
mov ebx, [esp]
pushad
call deltakiuser
deltakiuser:  pop ebp
sub ebp, offset deltakiuser
cmp dword ptr[ebx], EXCEPTION_BREAKPOINT
je __bp_conditions
cmp dword ptr[ebx], EXCEPTION_GUARD_PAGE
jne retkiuser0
...
retkiuser0:   popad
mov [ecx.context_dr0], 0
retkiuser:     push 0deadc0deh <--- 在hook引擎中改变此值使其指向正确的值
ret           
因为KiUserExceptionDispatcher是ntdll.dll的输出函数,我们可以简单的使用GetProcAddress定位它并且找到我们要hook的地址。同样注意,我一直使用的都是偏移地址,因为所有的代码都是在我注入的代码中执行的:
mov eax, [ebp+KiUserExceptionDispatcher]
lea ebx, [ebp+nonintrusive]
mov byte ptr[eax], 0e9h
mov ecx, eax
add ecx, 5
sub ebx, ecx
mov dword ptr[eax+1], ebx
add eax, 7
mov dword ptr[ebp+retkiuser+1], eax
有了这个钩子,你也许就可以肯定了,所有的异常都会经过你的钩子,你可以处理它们。当然非侵入式跟踪器也有弊端,因为这些理论不适用于两个进程比如当我们使用调试器/被调试程序的时候,因为这些异常可能会被调试器处理。另一方面,如果所有的异常都传递为DBG_EXCEPTION_NOT_HANDLED我们的跟踪器就会毫无问题的工作。只是一个想法,但是为什么不hook WaitForDebugEvent并且将所有的异常传递为DBG_EXCEPTION_NOT_HANDLED呢。只是设想,思考一下,读一下5.3章。 

非侵入式跟踪的第二个也是最大的一个缺陷就是处理堆栈时大量的垃圾代码。如果我们仔细的看看Exception异常的处理过程我们会看到CONTEXT和Exception代码由ring0拷贝到了用户堆栈。如果某些多态指令混淆了堆栈,拷贝就不会被执行,程序也就随之崩溃了。这是一种特别的情况,当我们单步跟踪代码的时候我们必须检查下一条指令是不是要改变堆栈,这样我们就可以阻止单步跟踪或者在我们的跟踪器中模仿这样的指令。这一点,我要推荐一下z0mbie的XDE engine[7]。

好了,希望你了解了基本思路。
4.2. 非侵入式跟踪器中使用PAGE_GUARD
PAGE_GUARD被用来提供简单好用的访问警告,但是也要注意到一旦异常发生了,PAGE_GUARD不会被移除。调试器 (也就是我们的非侵入是跟踪器) 应该在跳转到异常发生的地方执行前应该先改变页的的保护属性。我们也可以使用PAGE_NOACCESS,我们要使用这样或者那样的方法改变错误页的保护属性。但是使用PAGE_GUARD的优点源于这样一个事实:我们同样知道了错误代码传给了我们的跟踪器,这是我们使用的PAGE_GUARD唯一原因。同样也可以在保护器中使用对地址0x00000000h处的内存访问,通常代码是下面这个样子:
xor eax, eax
push offset sehhandle
push dword ptr fs:[eax]
mov dword ptr fs:[eax], esp
mov [eax], eax <-- 异常
我们必须手动确定异常是由于寄存器的错误使用而产生的,还是由于我们的页被设置成了page_noaccess(不可访问属性)而产生的。在 4.3 我还会涉及到如何区分寄存器的错误使用和读/写我们的内存区域。要设置PAGE_GUARD我们得回到内存管理器部分并且将这个属性设置到一定的内存范围,使用下面的方法:
__setpageguard:   or memprotection, PAGE_GUARD
lea eax, dummy_var
          push eax
          push memprotection
          push range
          push virtualaddress
          call [edi+VirtualProtect]
太简单了是吧? 这部分要存储在我们每个内存管理器的后面。很简单,现在你应该也明白了为什么所有的分配的内存缓冲区的都是以页为边界的。因为不管你怎么做都VirtualProtect将可访问的起始地址设为:
Address to set access = address and 0FFFFF000h
访问错误的页简单的使用FPI,多亏有了FPI,我们可以容易的找到VirtualAddress以及任何给定区域的大小:
getfpi: push esi
 mov esi, [ebp+memstart]
     add esi, NEW_MEM_RANGE-4000h
     sub eax, [ebp+memstart]
     shr eax, 0ch ;获得当前页的索引
     mov eax, [esi+eax*4]
     shr eax, 2 ;获得 FPI
     pop esi
     retn
;eax=FPI?,返回页的基址
getvafromfpi: 
     shl eax, 0ch
     add eax, [ebp+memstart]
     retn
;eax FPI, 由getfpi中返回。本函数返回大小
getsizefromfpi:
     push esi ecx edx ebx
     mov esi, [ebp+memstart]
     add esi, NEW_MEM_RANGE-4000h
     mov ecx, eax
     xor ebx, ebx
__cycle_fpi: 
     mov edx, [esi+ecx*4]
     shr edx, 2
     cmp edx, eax ;比较各FPI
     jne __gotsizefromfpi
     inc ebx
     inc ecx
     jmp __cycle_fpi
__gotsizefromfpi: 
     shl ebx, 0ch
     mov eax, ebx
     pop ebx edx ecx esi
     retn
现在,要是你不喜欢我的方法,如何实现不同的内存管理也可以取决于你,完全取决于你,本文的主要目的只是想让你理解这个方法的思路。如果你正在像我一样思考,你应该会问了:怎样才能知道是由于对受保护页的执行引发了EXCEPTION_GUARD_PAGE还是对受保护页的读写引发了异常呢?不使用ring0这几乎是不可能的。我说几乎是因为如果肯定了是对受保护页的读写引发了异常,你就必须在你的内存管理器中跟踪受保护页的信息。 
4.3.特别情况下的PAGE_GUARD
就像我在编写自己的小程序的时候一样。我意识到由KiUserExceptionDispatcher传回的数据已经不足以满足我的需要了。我的处境很可能是一个程序员能够碰到的最糟糕的处境了。我想做的是迫使使用了Advanced OEP protection(高级OEP保护手段)的 ASProtect SKE 2.2将分配的所有缓存都存储到一个大缓存以方便以后转储。为了找到ASPR混淆后的多态变形的oep,我计划在每个新分配的缓存上使用PAGE_GUARD,这样我就可以确定什么时候EIP到达缓存并在这个时候登入,但此时一个大问题来了。

因为所有的缓存都位于一个大内存缓冲区中,而我的代码用作了内存管理器(释放/分配内存页),我无法确定什么时候eip到达了某个可能的范围中,因为在缓冲内存中这样的范围实在是太多了。实际上我所需要的不是作为信息传递给KiUserExceptionDispatcher的东西,对,我需要的是cr2寄存器的内容,这样就能得到错误地址,这样如果EIP和cr2的内容相匹配,就说明我们到达了自己的受保护页。
Ring3中这一点的解决方案就是保存每个内存页的信息,这样一旦发生了PAGE_GUARD异常,我们就可以确定这个PAGE_GUARD异常发生时,EIP是在一个没有PAGE_GUARD属性的页中还是在一个标记了PAGE_GUARD的内存页中。是个不错的主意,但是需要编写更多的代码,还需要重新组织我的ring3的内存管理器,增加对每个内存页的保护功能。因为我懒得重新编写,所以我就又为KiUserExceptionDispatcher添加了一小段代码。

当然也可能hook KiTrap0E 并且在context结构中返回cr2寄存器的内容。嗯,不错的想法。cr2寄存器将会保存有错误发生的va(虚拟地址),因为对受保护页的访问只不过是触发异常,我们除了得到cr2寄存器的值还要确定异常是由于执行产生的还是由于读写产生的。当然,KiUserExceptionDispatcher是不会替我们返回出错地址的,就是因为糟糕的这一点我们必须改进它,使它能返回cr2寄存器的值:
cr2 = faulting_address0 //aal注:出错地址
eip = faulting_address0 //aal注:出错地址
exceptioncode = EXCEPTION_GUARD_PAGE//错误代码 = EXCEPTION_GUARD_PAGE
爽,登入它,去除这一页的保护,然后等待下次访问。但是如何得到cr2寄存器的内容呢???是啊,这的确是个头痛的问题,但是实际上确实是可以做到的。来看看KiTrap0E是怎么做的:
.text:804DAF25 _KiTrap0E:
.text:804DAF25 mov word ptr [esp+2], 0
.text:804DAF2C push ebp
.text:804DAF2D push ebx
.text:804DAF2E push esi
...
.text:804DB0ED mov ecx, 3
.text:804DB0F2 mov edi, eax
.text:804DB0F4 mov eax, 0C0000006h
.text:804DB0F9 call CommonDispatchException

到这里所有的KiTrap调用将会调用CommonDispatchException,这个函数会将CONTEXT 和ERROR_CODE的内容保存到堆栈中并且将EIP重新引导到KiUserExceptionDispatcher。继续跟踪CommonDispatchException,我们来到这里:
.text:804D8A8D CommonDispatchException proc near
.text:804D8A8D
.text:804D8A8D
.text:804D8A8D
.text:804D8A8D sub esp, 50h
.text:804D8A90 mov [esp+50h+var_50], eax
.text:804D8A93 xor eax, eax
.text:804D8A95 mov [esp+50h+var_4C], eax
.text:804D8A99 mov [esp+50h+var_48], eax
.text:804D8A9D mov [esp+50h+var_44], ebx
.text:804D8AA1 mov [esp+50h+var_40], ecx
.text:804D8AA5 cmp ecx, 0
.text:804D8AA8 jz short loc_804D8AB6
.text:804D8AAA lea ebx, [esp+50h+var_3C]
.text:804D8AAE mov [ebx], edx
.text:804D8AB0 mov [ebx+4], esi
.text:804D8AB3 mov [ebx+8], edi
.text:804D8AB6
.text:804D8AB6 loc_804D8AB6:
.text:804D8AB6 mov ecx, esp
.text:804D8AB8 test dword ptr [ebp+70h], 20000h
.text:804D8ABF jz short loc_804D8AC8
.text:804D8AC1 mov eax, 0FFFFh
.text:804D8AC6 jmp short loc_804D8ACB
.text:804D8AC8
---------------------------------------------------------------------
.text:804D8AC8
.text:804D8AC8 loc_804D8AC8:
.text:804D8AC8 mov eax, [ebp+6Ch]
.text:804D8ACB
.text:804D8ACB loc_804D8ACB:
.text:804D8ACB and eax, 1
.text:804D8ACE push 1
.text:804D8AD0 push eax
.text:804D8AD1 push ebp
.text:804D8AD2 push 0
.text:804D8AD4 push ecx
.text:804D8AD5 call _KiDispatchException@20
.text:804D8ADA mov esp, ebp
.text:804D8ADC jmp Kei386EoiHelper@0
.text:804D8ADC CommonDispatchException endp
好了,因为知道早晚都会调用_KiDispatchException所以我们就直接跟到这里了:
.text:804F318D ; __stdcall KiDispatchException(x,x,x,x,x)
.text:804F318D _KiDispatchException@20 proc near
.text:804F318D
.text:804F318D push 390h
.text:804F3192 push offset dword_804F3278
.text:804F3197 call __SEH_prolog
.text:804F319C mov eax, ds:___security_cookie
.text:804F31A1 mov [ebp-1Ch], eax
.text:804F31A4 mov esi, [ebp+8]
.text:804F31A7 mov [ebp-2ECh], esi

.text:804F31AD mov ecx, [ebp+0Ch]
.text:804F31B0 mov [ebp-2F0h], ecx
.text:804F31B6 mov ebx, [ebp+10h]
.text:804F31B9 mov [ebp-2F8h], ebx
.text:804F31BF db 3Eh
.text:804F31BF mov eax, ds:0FFDFF020h
.text:804F31C5 inc dword ptr [eax+504h]
.text:804F31CB mov dword ptr [ebp-2E8h], 10017h
.text:804F31D5 cmp byte ptr [ebp+14h], 1
.text:804F31D9 jz loc_804F5A76
.text:804F31DF cmp ds:_KdDebuggerEnabled, 0
.text:804F31E6 jnz loc_804F5A76
.text:804F31EC
.text:804F31EC loc_804F31EC:
.text:804F31EC
.text:804F31EC lea eax, [ebp-2E8h]
.text:804F31F2 push eax
.text:804F31F3 push ecx
.text:804F31F4 push ebx
.text:804F31F5 call _KeContextFromKframes@12
.text:804F31FA mov eax, [esi]
.text:804F31FC cmp eax, 80000003h
.text:804F3201 jnz loc_804F5A20
.text:804F3207 dec dword ptr [ebp-230h]
.text:804F320D
.text:804F320D loc_804F320D:
.text:804F320D
.text:804F320D xor edi, edi
.text:804F320F
.text:804F320F loc_804F320F:
.text:804F320F cmp byte ptr [ebp+14h], 0
.text:804F3213 jnz loc_804F58C3
.text:804F3219 cmp byte ptr [ebp+18h], 1
.text:804F321D jnz loc_80516D98
.text:804F3223 mov eax, ds:_KiDebugRoutine
.text:804F3228 cmp eax, edi
.text:804F322A jz loc_80505721
.text:804F3230 push edi
.text:804F3231 push edi
.text:804F3232 lea ecx, [ebp-2E8h]
.text:804F3238 push ecx
.text:804F3239 push esi
.text:804F323A push dword ptr [ebp-2F0h]
.text:804F3240 push ebx
.text:804F3241 call eax
.text:804F3243 test al, al
.text:804F3245 jz loc_80505721
.text:804F324B
.text:804F324B loc_804F324B:
.text:804F324B push dword ptr [ebp+14h]
.text:804F324E push dword ptr [ebp-2E8h]
.text:804F3254 lea eax, [ebp-2E8h]
.text:804F325A push eax
.text:804F325B push dword ptr [ebp-2F0h]
.text:804F3261 push ebx
.text:804F3262 call _KeContextToKframes@20
.text:804F3267
.text:804F3267 loc_804F3267:
.text:804F3267
.text:804F3267 mov ecx, [ebp-1Ch]
.text:804F326A call @xHalReferenceHandler@4

.text:804F326F call __SEH_epilog
.text:804F3274 retn 14h
.text:804F3274 _KiDispatchException@20 endp ; sp = -14h
.text:804F3274
如果不实际的跟踪一下,就看不出这些代码有什么意义,所以我们可以使用Softice开始跟踪,很快我们就会发现我们想要的字节:
.text:804F5959 call _ProbeForWrite@12
.text:804F595E mov ecx, 0B3h
.text:804F5963 lea esi, [ebp+var_2E8]
.text:804F5969 rep movsd
804F5969处的指令负责将CONTEXT拷贝到用户堆栈,当然是为KiUserExceptionDispatcher做这个工作。如果我们能够hook那条指令,我们当然也就能够保存cr2寄存器的内容,并将它的值放到CONTEXT结构的某个域中传回ring3。这些域就是dr0/4 regs。如果我们不处理它,我们的非侵入式跟踪器应该清除cr2的值。
我们将要hook "lea esi, [ebp+var_2E8]" 处,因为它的大小足够保存hook-也就是push/ret组合,注意千万不要hook rep movsd处,否则驱动卸载的时候有可能发生BSOD(aal注:Blue Screen of Death,蓝屏死机)的错误。只是有可能发生,正像Mark Russinovich所描述的,当驱动的地址空间中的代码在执行时,如果打断了某个线程,而我们在这个时候卸载驱动,当被中断的线程打算再次执行时就会返回到空内存而你会得到一个内存页错误(PAGE_FAULT),看到的就是BSOD。我还没有遇到过这种情况,但是这的确是有可能发生的。只要你遭遇到BSOD却一点也不知道是什么原因造成的时候,脑子中想到这一点就行。
同样我们必须标识我们的进程,完成这一点,我使用的是cr3技巧,因为cr3中存储了PDE的物理地址,因为每个进程都有自己的内存空间,那样我们就能毫无问题的标识进程了 [11]。注意我使用的KeStackAttachProcess是不必要的, 我们可以直接在KPROCESS结构(EPROCESS的一部分)中得到cr3的值:
kd> dt nt!_KPROCESS
+0x000 Header : _DISPATCHER_HEADER
+0x010 ProfileListHead : _LIST_ENTRY
+0x018 DirectoryTableBase : [2] Uint4B <-- 就是这里了 
+0x020 LdtDescriptor : _KGDTENTRY
ntoskrnl.exe基址的偏移地址25963h处,我们现在就可以准备完成这个技巧了。开始干吧!!!!你必须在你自己的ntoskrnl.exe中看看这个偏移地址,因为这个偏移地址可能会有所不同。
另外还有一种条件非常非常非常重要,这就是cr2的保存!!!
cr2的值没有被保存,如果其它的内存页错误,cr2就会保存那个错误地址。 这对我们来说使非常危险的,因为内存页错误的处理程序同样也用来将内存页存储在页面文件中。有一种存储cr2的值的方法就是hook int 0eh,它能够在当且仅当cr2的值属于我们的进程时存储它。接下来的代码中,我们的hook将会得到已经存储了的cr2的值,并且将它的值放到context.context_dr0中传给我们的代码。
hookint0eh label dword
push eax
mov eax, cr3
cmp eax, c_cr3
jne __exit_int0e
mov eax, cr2
mov c_cr2, eax
__exit_int0e: 
pop eax
jmp cs:[oldint0eh]
;将 cr2 的内容保存在 context.context_dr0 中
hookmycode label dword
push eax
mov eax, cr3
cmp eax, c_cr3
jne __exithook
lea esi, [ebp-2e8h]
mov eax, c_cr2
mov [esi.context_dr0], eax
__exithook: 
pop eax
lea esi, [ebp-2e8h]
retaddr: 
push 0deac0deh
ret
现在在你的非侵入式跟踪器中你只要测试是否EIP = DR0,如果相等的话我们就登入访问,而如果不是我们就去除PAGE_GUARD然后在错误指令后设置int 3h。我们有改进后的KiUserExceptionDispatcher替我们作我们想做的事。 
同样这也将帮助我们分别访问我们的内存页时产生的ACCESS_VIOLATION时我们是不是在使用PAGE_NOACCESS而不是在使用PAGE_GUARD。
4.4. 进入内存区域并访问
一旦我们知道了如何解决问题,我们就得进入我们的内存页面。这可以通过向文件进行写入操作来实现,或者更好一点的方法是使用www.sysinternals.com 的OutputDebugStringA和DbgView得到输出:
示例代码:
__log: lea ecx, [ebp+format5]
    lea ebx, [ebp+buffer]
    push eax
    push ecx
    push ebx
    call [ebp+wsprintfA]
    add esp, 0ch
    push ebx
    call [ebp+OutputDebugStringA] ;登入 访问
    ...
    format5 db "eip log : 0x%.08X", 13, 10, 0
现在,你应该在DbgView中得到了许多数据,保存下日志数据,研究一下地址,排出掉重复的部分,并且通过再次运行跟踪器缩减日志文件的大小。
4.5. 从跟踪器中触发驱动
这个实际上用来通知驱动要关心什么样PID,此时驱动已经安装好了,但是我们还得告诉它开始跟踪我们:
push 0
push 0
push OPEN_EXISTING
push 0
push 0
push GENERIC_READ or GENERIC_WRITE
push offset driver
callW CreateFileW
mov dhandle, eax
call DeviceIoControl, eax, 20h, o pid, 4, o pid, 4, o dwbytes, 0
push dhandle
callW CloseHandle
...
driver: unis <//./ring0>
pid = pid of process we are tracing//我们所跟踪程序的pid
我还使用了文章Loader from ring0 [11]中讲述的相同的方法来标识进程。
4.6. 制作stealth非侵入式跟踪器
好,这里假定我们对付的是个新壳,它会在KiUserExceptionDispatcher 中寻找我们的hook,我们要做的就是打败它。怎样才能做到这一点呢?的确不是简单的任务,多亏有了yates展示的技巧[8]我们能过继续下去。
KiUserExceptionDispatcher是一个永远永远都不会返回的过程,它将要调用NtContinue或者是NtRaiseException。我们看看都发生了什么:
- 异常发生
- ntoskrnl.exe通过KiTrapXX接手控制权
- KiTraps实际上是IDT的入口(?),并且根据不同的异常在KiTrapXX的入口处有两种可能的堆栈布局:
+---------------+    +---------------+
|     EFLAGS      |    |      EFLAGS     |
+---------------+    +---------------+
|       CS         |   |         CS       |
+---------------+   +---------------+
|       EIP        |   |        EIP     |
+---------------+   +---------------+
            |   Error Code   |
            +---------------+

因为一些异常并不引发错误,同时也为了从ring0中退出来时更容易些,不管什么异常发生了一些KiTrapXX都将0压入堆栈模仿代码,比如KiTrap01和KiTrap03:
_KiTrap01
0008:804D8D7C PUSH 00 <--- dummy Error Code
0008:804D8D7E MOV WORD PTR [ESP+02],0000
0008:804D8D85 PUSH EBP
0008:804D8D86 PUSH EBX
0008:804D8D87 PUSH ESI
0008:804D8D88 PUSH EDI
0008:804D8D89 PUSH FS
_KiTrap03
0008:804D915B PUSH 00 <--- dummy Error Code
0008:804D915D MOV WORD PTR [ESP+02],0000
0008:804D9164 PUSH EBP
0008:804D9165 PUSH EBX
0008:804D9166 PUSH ESI
0008:804D9167 PUSH EDI
0008:804D9168 PUSH FS
但是KiTrap0E (内存页错误处理程序) 并没有将0压入堆栈因为错误代码存在了堆栈中。
_KiTrap0E
0008:804DAF25 MOV WORD PTR [ESP+02],0000
0008:804DAF2C PUSH EBP
0008:804DAF2D PUSH EBX
0008:804DAF2E PUSH ESI
0008:804DAF2F PUSH EDI
0008:804DAF30 PUSH FS
0008:804DAF32 MOV EBX,00000030
从中断中返回是由一个简单的IRETD指令完成的,它与ret指令相近,也是跳转到堆栈中所保存的EIP。异常处理完毕之后,ring0确定要调用KiUserExceptionDispatcher时它就会将KiUserExceptionDispatcher的地址存储在堆栈中,所以IRETD只是简单的返回了KiUserExceptionDispatcher :
0008:804F5A0F MOV EAX,[_KeUserExceptionDispatcher]
0008:804F5A14 MOV [EBX+68],EAX
:dd ebx+68
0010:EEC21DCC 7C90EAEC 0000001B 00000246 0013FCD0 ìê |....F.......
0010:EEC21DDC 00000023 00000000 00000000 00000000 #...............
正如你所看到的,EIP被KiUserExceptionDispatcher的地址覆盖了以及堆栈中保存的CS,Eflags,esp 和 SS。因为我们要做的是hook这些指令,所以他会指向ntdll.dll中其他的代码,就是我们使用yates展示的方法所存储的那些代码。 同样,也有更好的方法应该尽量不要扫描磁盘上的ntdll.dll,而是使用内存中已经载入的文件直接重新引导至UserSharedData,在用户模式下被设置成了只读:
kd> ? SharedUserData
Evaluate expression: 2147352576 = 7ffe0000
kd>
但是在ring0它被映射到了:
#define KI_USER_SHARED_DATA 0xffdf0000
所以我们可以在ring0中向那里写入,并且重新引导我们的异常(Exceptions)到达负责跳转到KiUserExceptionDispatcher 的地方,或者只是简单的调用我们存储在被调试程序内存空间某处的非侵入式跟踪器。见[ring0stealthtracer]文件夹。在你运行之前确定你理解了这些代码将要做什么。我这次使用的是PID标识进程。我们首先定位ntoskrnl.exe的基址: 
iMOV esi, ZwCreateFile
and esi, 0FFFFF000h
__find_base: cmp word ptr[esi],'ZM'
je __ntoskrnlbase
sub esi, 1000h
jmp __find_base
__ntoskrnlbase: mov ntoskrnlbase, esi
然后我们必须定位_KeUserExceptionDispatcherVariable(变量),它没有被输出。对我们来说,幸运的是它被存储在一个以双字(dword)对齐的边界处,而且在ntoskrnl.exe中KiUserExceptionDispatcher只出现了一次,所以我们可以使用KiUserExceptionDispacther的地址搜索它:
mov edi, esi
mov ebx, esi
add ebx, dword ptr[ebx+3ch]
mov ecx, [ebx.NT_OptionalHeader.OH_SizeOfImage]
shr ecx, 2
cld
mov eax, kiuser
repnz scasd
sub edi, 4
一旦我们定位在未输出的的_KeUserExceptionDispatcher我们就可以将我们的代码保存在UserSharedData然后将_KeUserExceptionDispatcher覆盖成我们代码的地址:
push edi
mov KiUserExceptionDispatcher, kiuser
mov edi, kiusershareddata+100h
mov esi, offset shareddatahook
mov ecx, shareddatahooksize
cld
rep movsb
pop edi
mov dword ptr[edi], kiusershareddataring3+100h
...
kiusershareddata equ 0ffdf0000h
kiusershareddataring3 equ 07ffe0000h

完工!
让我们看看如果用softICE跟踪的话是什么样子:
_KiTrap03
0008:804D915B PUSH 00
0008:804D915D MOV WORD PTR [ESP+02],0000
0008:804D9164 PUSH EBP
0008:804D9165 PUSH EBX
0008:804D9166 PUSH ESI
0008:804D9167 PUSH EDI
0008:804D9168 PUSH FS
...
0008:804F5A0F MOV EAX,[_KeUserExceptionDispatcher]
0008:804F5A14 MOV [EBX+68],EAX
0008:804F5A17 OR DWORD PTR [EBP-04],-01
… :
dd _KeUserExceptionDispatcher
0023:80552AF0 7FFE0100 7C90EAD0 7C90EAC0 00002626 ... .ê |.ê |&&..
这里是iretd之前的堆栈:
0023:F0E9DDCC 7FFE0100 0000001B 00000246 0013FCC8 ... ....F.......
0023:F0E9DDDC 00000023 00000000 00000000 00000000 #...............
然后我们就到了我们存在UserSharedData中的代码:
001B:7FFE0100 CALL 7FFE0105
001B:7FFE0105 POP EDX
001B:7FFE0106 SUB EDX,F7129409
001B:7FFE010C MOV ECX,FS:[0020] <--从TEB中得到PID
001B:7FFE0112 CMP DWORD PTR [EDX+F712943B],-01 <-- 不跟踪
001B:7FFE0119 JZ ntdll!KiUserExceptionDispatcher   到KiUser跳转
001B:7FFE011B CMP [EDX+F712943B],ECX <-- 被跟踪的程序的PID么?
001B:7FFE0121 JNZ ntdll!KiUserExceptionDispatcher 不是,我们调用KiUser
001B:7FFE0123 JMP [EDX+F7129437] <-- 我们调到nonintrusive
001B:7FFE0129 JMP [EDX+ntdll!KiUserExceptionDispatcher] <-- 回到KiUser
不是被跟踪的程序,所以我们继续对KiUserExceptionDispatcher的访问:
ntdll!KiUserExceptionDispatcher
001B:7C90EAEC MOV ECX,[ESP+04]
001B:7C90EAF0 MOV EBX,[ESP]
001B:7C90EAF3 PUSH ECX
001B:7C90EAF4 PUSH EBX
希望你理解了stealth技术? 祝未来的保护器好运!!
注意这个技巧是 Barnaby Jack 在他关于ring0shell code著名的文章 [9] 。.

4.7. 非侵入式跟踪器结论
到这里,希望你已经明白了基本思路,我提供的所有的代码和示例都是以在ntoskrnl.exe 中放置hook为目的的,所以你需要将我的驱动源代码中的偏移地址修改为你自己的所有的驱动代码都只是未经修正过的,如果没有提前修改而因为错误的偏移地址导致了蓝屏死机,系统崩溃,数据丢失等均由使用者本人负责。







5. 装载器的装载器
装载器的装载器实际上是处理双进程保护的装载器。一些软件使用装载器装载真正的游戏,我已经做了一个处理这种软件的装载器。因为这样就存在了两个装载器所以我把它称作装载器的装载器。网上很可能已经有我写的关于这方面的文章,但是在这里我还是想描述一下,主要是因为我们所对付的就有一些双进程的保护壳。我们必须获得第二个进程的控制权,为了达到这个目的我们将要hook的是CreateProcessA,这样等它完成了自己的任务时,我们就可以得到一个PROCESS_INFORMATION结构,这个结构中包含了ProcessID和ThreadID。
- ProcessId 有了它我们就可以打开进程然后在它的缓存地址空间写东西等等…
- ThreadId 有了它我们才能控制某些东西,Get/SetThreadContex,Resume/SuspendThread。
注意:如果我们是从注入的偏移地址无关代码中hook我们就不需要了,因为我们已经有了保存在PROCESS_INFORMATION结构中的ProcessHandle和ThreadHandle。
我将讲述3种方法,其中一种有可能还没有在RCE领域中被讨论过。
5.1. 使用注入代码实现的装载器的装载器
这种方法的思路是将我们的内存管理器注入到第二个进程中,整个过程涉及到在返回(ret)时hook CreateProcessA,此时我们会得到一个错误代码(如果进程的执行没有问题的话)。我们还会得到一个填充好了的PROCESS_INFORMATION结构,其中就包含了进程和主线程的句柄。从根本上讲,就会有两套偏移无关代码 。示例代码在[lflinjected]文件夹中。
我们来看一眼我们的例子,在第二个装载器的装载器中我还要用到这个测试(test)程序,所以脑子里记住这里我们是怎么处理的:
push offset mutexname
push 0
push MUTEX_ALL_ACCESS
callW OpenMutexA
test eax, eax
jnz __2ndprocess
push offset mutexname
push 0
push 0
callW CreateMutexA
push offset pinfo
push offset sinfo
push 0
push 0
push 0
push 0
push 0

push 0
push offset progy
push 0
callW CreateProcessA
push -1
push pinfo.pi_hProcess
callW WaitForSingleObject
jmp __exit
__2ndprocess: push 40h
push offset mtitle
push offset mtext
push 0
callW MessageBoxA
__exit: push 0
callW ExitProcess
看到了吧,这个小程序是要生成一个互斥对象(mutex),然后根据互斥对象的情况来决定是输出信息还是产生一个新的进程实例。为了实现装载器的装载器,我们用注入代码的第一部分hook函数CreateProcessA,第二部分用来注入。从根本上讲这里出现了两个偏移无关代码,索性的是获得kernel的基址(getkernelbase)和从kernel32.dll搜索输出函数的工作,我们只需要进行一次,因为kernel32.dll偏移地址是不会因为不同的进程而有所不同的。
思路:
- 使用CreateProcess生成挂起状态的进程
- 注入偏移无关代码
- 使用偏移无关代码hook我们目标进程中的CreateProcessA
- 一旦运行到了CreateProcessA返回(retn)处的hook就得到了进程和线程句柄
- 到这里我们的偏移无关代码就要使用WriteProcessMemory和VirtualAllocEx将一个新的装载器注入到第二个进程中,此处从根本上讲我们是在重复我们的原始的装载器中注入偏移无关代码的工作
由于第一个装载器的大小的问题,源代码就不在这里贴出了,你可以在[lflinjected]文件夹中找到已经经过注释的源代码文件。
5.2. 不使用注入代码实现的装载器的装载器
这是更为简单的一个解决方案,也仅需要较少的偏移无关代码的编写和理解。但是如果进程不是使用CREATE_SUSPENDED创建的,这种方法就不太好了,因为我们必须手动的加入CREATE_SUSPENDED标志,这样甚至反倒需要更多的代码。
思路:
- 使用CREATE_SUSPENDED标志载入第一个程序
- 在CreateProcessA中插入jmp $
- 运行到我们的hook时,从堆栈中读取PROCESS_INFORMATION的地址,dwCreationFlags和返回地址
- 将dwCreationFlags与CREATE_SUSPENDED进行或操作并且将它存回堆栈,清除前面的hook并在堆栈的所指向的返回地址插入新的指令
- 运行到我们的第二个hook时,读取PROCESS_INFORMATION我们会得到pid和tid
- 使用OpenProcess和OpenThread处理新进程
在[lflwoinjected]文件夹中查看完整的实现代码,已注释。
5.3.用于被调试进程的非侵入式跟踪器 
我这里用的是一个使用Armadillo 4.3的Standard Protection + Debug Blocker加壳的crackme来注入我的跟踪器。当然这对内存管理器也适用,但为了简单明了的说明问题,我只把非侵入式的跟踪器注入到其中并且中断在OEP处。. Lol, guess what you got DebugBlocker
armadillo oep finder with this tutorial.
其实实践起来并不难,你所需要的只是一点点想象力,以及一些如何使用Windows Debug APIs [10]实现ring3级的调试的知识,你可以参考一些调试装载器的教程。要实现这一点我们要hook WaitForDebugEvent以便我们第一时间得到它的输出值然后才能够检查DEBUG_EVENT结构的内容,我们只对传给我们的代码的EXCEPTION_DEBUG_EVENT感兴趣,而所有其他的事件都直接传回给程序。我使用带DBG_EXCEPTION_NOT_HANDLED参数的ContinueDebugEvent,它会在我们的被跟踪程序中调用KiUserExceptionDispatcher。为了达到目的,我们还得在armadillo中CreateProcessA的返回处(retn)hook,这样才能将我们的非侵入式跟踪器注入。

代码和目标程序都在文件夹[armadillo_oep]中,既然到了这里,还要感谢一下Teddy Rogers和他收集unpackme的网站http://www.tuts4you.com/unpackme/ ,因为我们将要使用的unpackme就来自这里
我们来看看调用了CreateProcessW后armadillo又干了什么:
004949EC . 52 PUSH EDX
004949ED . 6A 02 PUSH 2
004949EF . 68 A4B44C00 PUSH armadill.004CB4A4
004949F4 . 8B45 10 MOV EAX,DWORD PTR SS:[EBP+10]
004949F7 . 50 PUSH EAX
004949F8 . 8B4D 08 MOV ECX,DWORD PTR SS:[EBP+8]
004949FB . 8B11 MOV EDX,DWORD PTR DS:[ECX]
004949FD . 52 PUSH EDX
004949FE . FF15 D0304C00 CALL DWORD PTR DS:[<&KERNEL32.ReadProcessMem>
这里,Armadillo从入口处读取了2个字节,这样它就能够在这里插入jmp $ hook然后再使用DebugActiveProces附加进程了,当然,armadillo也使用Sleep/GetThreadContext来确定程序何时到达入口点,在无限循环中:
0048FD8D > /A1 A0B54C00 MOV EAX,DWORD PTR DS:[4CB5A0]
0048FD92 . 8B48 08 MOV ECX,DWORD PTR DS:[EAX+8]
0048FD95 . 51 PUSH ECX
0048FD96 . FF15 C4314C00 CALL DWORD PTR DS:[<&KERNEL32.DebugActivePro>

从这里开始我们就进入了一个使用WaitForDebugEvent和ContinueDebugEvent制造的调试循环(Debug Loop)。 “Hybrid” hooking of WaitForDebugEvent在这里是不行的,因为这里的ret附近没有填充的nop指令,所以我们要使用更巧妙一些的hook方法。
这个方法就是我们覆盖堆栈中所存储的返回地址,这样运行WaitForDebugEvent,当它试图返回时就会跳转到我们的代码了。


好,还没跑吧?或许一些图例将会帮助理解得更清楚些
+---------------+---------------+---------------+
|     返回地址     |     调试事件     |      毫秒      |
+-------+-------+---------------+---------------+
     |
  被钩子覆盖
     |
+-------+-------+---------------+---------------+
|      我的代码    |     调试事件     |      毫秒      |
+---------------+---------------+---------------+
现在一旦WaitForDebugEvent打算返回到它的调用者是就会到达我的代码,而我们就可以随心所欲的处理异常了: 
hooking代码示例:
; ;
首先我们需要几个字节插入我们的hook
;
mov esi, [edi+WaitForDebugEvent]
xor ecx, ecx
push esi
__get5bytes: push esi
call ldex86
add ecx, eax
add esi, eax
cmp ecx, 5
jb __get5bytes
pop esi
push edi
lea edi, [edi+rippedbytes]
cld
rep movsb
pop edi
mov dword ptr[edi+goback+1], esi
;;
将字节拷贝到可以安全运行的缓存区域
;
mov esi, [edi+WaitForDebugEvent]
mov ecx, esi
lea ebx, [edi+hookwaitfordebugevent]
mov byte ptr[esi], 0e9h
add ecx, 5
sub ebx, ecx
mov dword ptr[esi+1], ebx

被覆盖部分的字节保存在了rippedbytes变量中:
hookwaitfordebugevent label dword
pusha
call deltahook
deltahook: pop ebp
sub ebp, offset deltahook
mov eax, [esp+8*4]
;保存原来的返回地址
mov [ebp+waitfordebugeventretaddress], eax
mov dword ptr[ebp+retorig+1], eax
lea ebp, [ebp+mywaitfordebugevent]
mov [esp+8*4], ebp
popa
rippedbytes db 30 dup(90h)
goback: push 0deac0deh <- WaitForDebugEvent + ripped bytes
ret
经过我们的工作,WaitForDebugEvent完成后我们就会到达这里了: imywaitfordebugevent proc
mov ecx, [esp-8] ;这里是debugevent
pusha
同样要注意偏移地址是负值,因为ret和leave指令已经将堆栈对齐了,因为windows API使用的是stdcall约定(除了几个函数如wsprintfA,DbgPrint,还有其他几个???)。
这种情况下我访问变量的时候我们必须计算出负的偏移地址,同样还有小心,此时不要将任何值压入堆栈,因为每次入栈都将会破环存在堆栈中的数据,这些数据对我们使用的这种方法是至关重要的。
现在我们关心的就是异常的处理了,只处理EXCEPTION_DEBUG_EVENT:
mywaitfordebugevent proc
mov ecx, [esp-8] ;这里是debugevent
pusha
call deltamydebug
deltamydebug: pop edi
sub edi, offset deltamydebug
mov ebx, ecx
cmp [ebx.de_code], EXCEPTION_DEBUG_EVENT
jne __return_to_original
cmp [ebx.de_u.ER_ExceptionCode], EXCEPTION_BREAKPOINT
jne __passexception
cmp [edi+firstint3h], 1
je __passexception
;在远进程中设置页面保护
pushv <dd ?>
push PAGE_EXECUTE_READWRITE or PAGE_GUARD
push [edi+c_range]
push [edi+c_start]
push [edi+phandle]
call [edi+VirtualProtectEx]
push DBG_CONTINUE
push [ebx.de_ThreadId]
push [ebx.de_ProcessId]
call [edi+ContinueDebugEvent]
mov [edi+firstint3h], 1
jmp __l33t
__passexception: push DBG_EXCEPTION_NOT_HANDLED
push [ebx.de_ThreadId]
push [ebx.de_ProcessId]
call [edi+ContinueDebugEvent]
;;现在的问题是我们返回哪里呢?我们可以把修改过的错误代码保存在debug_event.code
;中,然后继续执行,因为我们知道再没有什么会在破坏我们的代码了。 

__l33t: mov [
ebx.de_code], 0deadc0deh ;l33t
__return_to_original: popa
retorig: push 0deadc0deh ;改成返回地址
ret
endp
我们只对第一个int 3h感兴趣,它产生于DebugBreak,它应该以ContinueDebugEvent(DBG_CONTINUE)的形式传递,其他的情况我们都使用ContinueDebugEvent(DBG_EXCEPTION_NOT_HANDLED)的形式将它传回被调试程序,让我们的非侵入式的跟踪器来处理被跟踪进程的内存区,同样,为了避免破坏我们的代码,我们将debug_event.code设置成0deadc0deh,这样它就不会再处理debug_event了, 或者我们也可以将ThreadId或ProcessId的值设成毫无意义的数值,这样ContinueDebugEvent就对我们程序什么都不做了。
现在一切都交给我们存在被调试程序的非侵入式的跟踪器了,等待知道你得到一个信息告诉你oep找到了。另外MessageBoxA的“Ok”按钮被点击后,我还在非侵入式跟踪器中插入了一条jmp $,所以现在你要做的就是转出进程并且,当然得从任务管理器或者是Mark Russinovish的进程管理器中结束它了。

祝你好运 
代码位于 [armadillo_oep]文件夹中。




6. 调试注入的代码 ? 几条建议
调试这种代码的要点就是能够看到第二个进程里的代码,尤其是我们面对的是loader的loader或者跟踪被调试程序的时候。
有几个技巧需要提一下:
- 在你的代码中使用int 3h或者在SoftICE中使用bpint 3 或者i3here on来中断在代码的可疑部分
- 对于装载器的装载器的情况,使用ADDR来看第二个进程中到底发生了什么
- 编写非侵入式跟踪器时使用jmp $和ctrl+d中断在softice,因为此时bpint 3和i3here on会因为非侵入式跟踪器的关系中断很多次,或者可以使用drX寄存器跳过错误指令然后代码中的int 3h就会起作用了

这些都是针对调试类似的装载器的几点建议,祝你好运当然这些技巧对调试注入的DLL时也适用。






























7. 结论
这里我展示我所能想到的一些技巧以及我编写的一些代码。希望这能够帮到一些人,特别希望能让那些入门级的逆向者明白在RCE的世界里编程的能力是多么重要。我的意见可能有人赞成,有人反对,有人喜欢,也有人唾弃,或者是破口大骂说我照你的代码怎么编译不成功?不管怎么说,我留下了许多东西都需要思考,要记住熟能生巧。如果你开始没有成功,那就再试一次,答案肯定就在附近的某个地方。为了搞清楚内存管理器和Delphi代码的关系,我用了两天的时间,而编译成功armadillo_oep的代码只花了4天。


我在这里展示的所有技巧的相关代码都同本文附在了一起,为了更好的理解文章,我建议你读以下源代码。另外,Tasm32 DDK也包含在了其中。















随同这篇教程的所有代码都可以随意公开使用,但使用时请在致谢里提及作者以及ARTeam。请不要使用文中的理论进行非法行为,文中的所有信息都是以学习以及帮助更好的理解程序代码的安全技术为目的的。


8. 参考文献
[1] Optimization of 32 bit code, Benny/29a, http://vx.netlux.org/29a/29a-4/29a-4.215
[2] Gaining important datas from PEB under NT boxes, Ratter/29a,
http://vx.netlux.org/29a/29a-6/29a-6.224
[3] Billy Belcebu Virus Writing Guide 1.0 for Win32, Billy Belcebu,
http://vx.netlux.org/29a/29a-4/29a-4.202
[4] Retrieving API’s addresses. LethalMind, http://vx.netlux.org/29a/29a-4/29a-4.227
[5] Solution to The Amazing Picture downloader, deroko,
http://www.crackmes.de/users/warrantyvoider/the_amazing_picture_downloader/
[6] Unpacking and Dumping ExeCryptor and coding loader for it, deroko,
http://tutorials.accessroot.com
[7] eXtended (XDE) disassembler engine, z0mbie, http://vx.netlux.org/29a/magazines/29a-8.rar
[8] Anti-Anti-Bpm, yates, http://www.yates2k.net/syscode/bpm.rar
[9] Remote Windows Kernel Exploatation - Step into ring0, Barnaby Jack,
http://www.eeye.com/~data/publish/whitepapers/research/OT20050205.FILE.pdf
[10] Win32 Debug API Part 1/2/3, Iczelion, http://win32asm.cjb.net/
[11] Loader from ring0, deroko, ARTeam eZine #1, http://ezine.accessroot.com
Some useful tutorials to learn about loaders theory:
[12] Shub-Niggurath and ThunderPwr coding loader series, http://tutorials.accessroot.com
[13] Createing Loaders & Dumpers ? Crackers Guide to Program Flow Control, yates,
http://www.yates2k.net/lad.txt
[14] Using Memory Breakpoints, Shub-Niggurath, http://tutorials.accessroot.com
一些很有用的文章:
[15] Dll Injection Tutorial, Darawk, http://www.edgeofnowhere.cc/viewtopic.php?p=2441382
[16] Three Ways to Inject Your Code into Another Process, Robert Kuster, The Code Project
http://www.codeproject.com/threads/winspy.asp
[17] InjLib ? A Library that implements remote code injection for all Windows versions,
Antonio Feijao, The Code Project, http://www.codeproject.com/library/InjLib.asp
有用的工具:
[18] OllyAdvanced plug-in, MaRKuS TH-DJM,
http://www.tuts4you.com/forum/index.php?showtopic=7092
http://omega.intechhosting.com/~access/forums/index.php?showtopic=2542
[19] LordPE plug-in, deroko, http://deroko.phearless.org/dumpdll/
[20] Ice-Ext, Stan, http://stenri.pisem.net/




9. 致谢
这里我要感谢所有的ARTeam的成员将他们的知识无私奉献,感谢29a病毒团队出品的最好的电子杂志,感谢我phearless电子杂志的朋友,感谢我在Reversing Labs论坛上的所有朋友,感谢所有伟大的程序员… …当然,还有感谢阅读本文的你。




 
http://cracking.accessroot.com
© [ARTeam] 2006 
import sys import os import json from functools import partial from tkinter import messagebox, filedialog import pandas as pd from PySide6.QtWidgets import ( QApplication, QMainWindow, QPushButton, QVBoxLayout, QHBoxLayout, QWidget, QLabel, QFileDialog, QMessageBox, QCheckBox, QScrollArea, QComboBox, QDateEdit, QDialog, QGridLayout, QFrame, QHeaderView, QTableWidget, QTableWidgetItem ) from PySide6.QtCore import Qt, QDate from PySide6.QtGui import QFont, QPixmap, QPainter, QColor # ========== 路径配置 ========== DATA_DIR = "data" PHOTO_DIR = "photo" # ✅ 修复拼写错误 PLAN_FILE = os.path.join(DATA_DIR, "plans.json") SELECTION_FILE = os.path.join(DATA_DIR, "class_selection.json") TEMPLATE_FILE = os.path.join(DATA_DIR, "导入模板.xlsx") PERSONNEL_FILE = os.path.join(DATA_DIR, "personnel.json") # 确保目录存在 os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(PHOTO_DIR, exist_ok=True) # 初始化文件 for file_path, default_data in [ (PLAN_FILE, {}), (SELECTION_FILE, {}), ]: if not os.path.exists(file_path): with open(file_path, 'w', encoding='utf-8') as f: json.dump(default_data, f, ensure_ascii=False, indent=2) if not os.path.exists(PERSONNEL_FILE): with open(PERSONNEL_FILE, 'w', encoding='utf-8') as f: json.dump({}, f, ensure_ascii=False, indent=2) if not os.path.exists(TEMPLATE_FILE): template_df = pd.DataFrame(columns=[ '岗位', '流程', '风险', '标准', '注意事项' ]) with pd.ExcelWriter(TEMPLATE_FILE, engine='openpyxl') as writer: template_df.to_excel(writer, index=False, sheet_name="模板") # ========== 数据加载与保存函数 ========== def load_plans(): """加载已有计划""" if not os.path.exists(PLAN_FILE): return {} try: with open(PLAN_FILE, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: QMessageBox.critical(None, "错误", f"读取计划失败:{e}") return {} def save_plan(name, data): """保存一个计划 JSON""" plans = load_plans() plans[name] = data with open(PLAN_FILE, 'w', encoding='utf-8') as f: json.dump(plans, f, ensure_ascii=False, indent=2) def save_class_selection(class_name, date_str, selected_plan_names, personnel=None): """保存班级的选择记录""" selections = {} if os.path.exists(SELECTION_FILE): with open(SELECTION_FILE, 'r', encoding='utf-8') as f: selections = json.load(f) selections[class_name] = { "date": date_str, "plans": selected_plan_names, "personnel": personnel or [] } with open(SELECTION_FILE, 'w', encoding='utf-8') as f: json.dump(selections, f, ensure_ascii=False, indent=2) def get_last_selection_by_class(class_name): """获取某班级上次选择的计划名列表(含人员)""" if not os.path.exists(SELECTION_FILE): return None try: with open(SELECTION_FILE, 'r', encoding='utf-8') as f: data = json.load(f) return data.get(class_name) except Exception as e: print(f"读取 selection 失败: {e}") return None # ========== 头像标签组件 ========== class CircularLabel(QLabel): def __init__(self, name, parent=None): super().__init__(parent) self.setFixedSize(80, 80) self.setStyleSheet("background: transparent;") self.name = name self.load_photo() def load_photo(self): photo_path = None for ext in ['.jpg', '.png', '.jpeg', '.gif']: candidate = os.path.join(PHOTO_DIR, f"{self.name}{ext}") if os.path.exists(candidate): photo_path = candidate break if not photo_path: self.setPixmap(self._create_placeholder()) return pixmap = QPixmap(photo_path) if pixmap.isNull(): self.setPixmap(self._create_placeholder()) return rounded = QPixmap(80, 80) rounded.fill(Qt.transparent) painter = QPainter(rounded) painter.setRenderHint(QPainter.Antialiasing) painter.drawPixmap(0, 0, pixmap.scaled(80, 80, Qt.IgnoreAspectRatio)) painter.setPen(Qt.NoPen) painter.drawEllipse(0, 0, 80, 80) painter.end() self.setPixmap(rounded) def _create_placeholder(self): pm = QPixmap(80, 80) pm.fill(QColor("#D3D3D3")) painter = QPainter(pm) painter.setRenderHint(QPainter.Antialiasing) painter.setFont(QFont("SimHei", 24, QFont.Bold)) painter.setPen(QColor("#555")) text = self.name[0] if self.name else "?" painter.drawText(pm.rect(), Qt.AlignCenter, text) painter.end() return pm # ========== 人员管理对话框 ========== class PersonManagementDialog(QDialog): def __init__(self, class_name, parent=None): super().__init__(parent) self.class_name = class_name self.personnel = self.load_personnel() self.all_posts = self.get_all_posts_from_plans() self.setWindowTitle(f"👥 管理【{class_name}】人员") self.resize(700, 500) self.setup_ui() def setup_ui(self): layout = QVBoxLayout() tip = QLabel("双击单元格可修改姓名或岗位。系统将自动检测头像是否存在。") tip.setStyleSheet("color: #555; font-size: 12px;") layout.addWidget(tip) self.table = QTableWidget(len(self.personnel), 4) self.table.setHorizontalHeaderLabels(["序号", "姓名", "岗位", "头像"]) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table.setEditTriggers(QTableWidget.DoubleClicked) layout.addWidget(self.table) self.refresh_table() btn_layout = QHBoxLayout() add_btn = QPushButton("➕ 增加一人") del_btn = QPushButton("🗑️ 删除选中") refresh_btn = QPushButton("🔄 刷新头像状态") save_btn = QPushButton("💾 保存并关闭") add_btn.clicked.connect(self.add_person) del_btn.clicked.connect(self.delete_selected) refresh_btn.clicked.connect(self.refresh_table) save_btn.clicked.connect(self.save_and_close) btn_layout.addWidget(add_btn) btn_layout.addWidget(del_btn) btn_layout.addStretch() btn_layout.addWidget(refresh_btn) btn_layout.addWidget(save_btn) layout.addLayout(btn_layout) self.setLayout(layout) def get_all_posts_from_plans(self): plans = load_plans() posts = set() for plan in plans.values(): for post in plan.keys(): if post.strip(): posts.add(post.strip()) return sorted(posts) def load_personnel(self): if not os.path.exists(PERSONNEL_FILE): return [] try: with open(PERSONNEL_FILE, 'r', encoding='utf-8') as f: data = json.load(f) class_data = data.get(self.class_name, []) return [ {"name": item.get("name", "").strip(), "post": item.get("post", "").strip()} for item in class_data ] except Exception as e: QMessageBox.critical(self, "读取失败", f"无法读取人员数据:\n{e}") return [] def save_personnel(self): all_data = {} if os.path.exists(PERSONNEL_FILE): try: with open(PERSONNEL_FILE, 'r', encoding='utf-8') as f: content = f.read().strip() if content: all_data = json.loads(content) except Exception as e: QMessageBox.warning(self, "警告", f"读取旧数据失败,将重新创建文件:\n{e}") all_data[self.class_name] = [{"name": p["name"], "post": p["post"]} for p in self.personnel] try: with open(PERSONNEL_FILE, 'w', encoding='utf-8') as f: json.dump(all_data, f, ensure_ascii=False, indent=2) except Exception as e: QMessageBox.critical(self, "错误", f"无法保存人员文件:\n{str(e)}") def refresh_table(self): self.table.setRowCount(len(self.personnel)) for i, p in enumerate(self.personnel): idx_item = QTableWidgetItem(str(i + 1)) idx_item.setFlags(idx_item.flags() ^ Qt.ItemIsEditable) self.table.setItem(i, 0, idx_item) name_item = QTableWidgetItem(p.get("name", "")) self.table.setItem(i, 1, name_item) combo = QComboBox() combo.addItems([""] + self.all_posts) current_post = p.get("post", "") index = combo.findText(current_post) if index >= 0: combo.setCurrentIndex(index) else: combo.addItem(current_post) combo.setCurrentText(current_post) self.table.setCellWidget(i, 2, combo) has_photo = self.check_photo_exists(p["name"]) photo_status = QLabel("✅" if has_photo else "未找到照片") photo_status.setAlignment(Qt.AlignCenter) photo_status.mousePressEvent = lambda e, row=i: self.select_photo_for(row) if e.button() == Qt.LeftButton else None self.table.setCellWidget(i, 3, photo_status) def check_photo_exists(self, name): if not name: return False for ext in ['.jpg', '.png', '.jpeg', '.gif']: path = os.path.join(PHOTO_DIR, f"{name}{ext}") if os.path.exists(path): return True return False def select_photo_for(self, row): name = self.personnel[row]["name"] if not name: QMessageBox.warning(self, "提示", "请先填写姓名!") return file_path, _ = QFileDialog.getOpenFileName( self, "为该成员选择头像", "", "图片文件 (*.jpg *.png *.jpeg)" ) if file_path: ext = os.path.splitext(file_path)[1].lower() target_path = os.path.join(PHOTO_DIR, f"{name}{ext}") try: import shutil shutil.copy(file_path, target_path) QMessageBox.information(self, "成功", f"✅ 已保存头像为:{name}{ext}") self.refresh_table() except Exception as e: QMessageBox.critical(self, "失败", f"无法复制文件:{e}") def add_person(self): self.personnel.append({"name": f"新成员{len(self.personnel)+1}", "post": ""}) self.refresh_table() def delete_selected(self): row = self.table.currentRow() if row < 0: QMessageBox.warning(self, "提示", "请先选中一行") return reply = QMessageBox.question(self, "确认", f"确定删除成员【{self.personnel[row]['name']}】?") if reply == QMessageBox.Yes: self.personnel.pop(row) self.refresh_table() def save_and_close(self): updated_data = [] for i in range(self.table.rowCount()): name_item = self.table.item(i, 1) name = name_item.text().strip() if name_item else "" widget = self.table.cellWidget(i, 2) post = widget.currentText().strip() if isinstance(widget, QComboBox) else "" updated_data.append({"name": name, "post": post}) self.personnel = updated_data self.save_personnel() QMessageBox.information(self, "保存成功", f"✅ 已保存【{self.class_name}】共 {len(self.personnel)} 名成员信息") self.accept() def get_data(self): return self.personnel.copy() # ========== 计划选择窗口 ========== class PlanSelectionWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("计划选择") self.resize(600, 500) self.selected_plans = set() self.class_name = "" self.date_str = "" self.init_ui() def init_ui(self): layout = QVBoxLayout() layout.setContentsMargins(30, 30, 30, 30) layout.setSpacing(20) title = QLabel("计划选择") title.setAlignment(Qt.AlignCenter) title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") layout.addWidget(title) row1 = QHBoxLayout() class_label = QLabel("班级:") self.class_combo = QComboBox() self.class_combo.addItems(["一班", "班", "三班", "四班", "五班"]) date_label = QLabel("日期:") self.date_edit = QDateEdit() self.date_edit.setDate(QDate.currentDate()) self.date_edit.setDisplayFormat("yyyy-MM-dd") self.date_edit.setCalendarPopup(True) row1.addWidget(class_label) row1.addWidget(self.class_combo) row1.addStretch() row1.addWidget(date_label) row1.addWidget(self.date_edit) layout.addLayout(row1) self.checkboxes_layout = QVBoxLayout() scroll = QScrollArea() scroll.setWidgetResizable(True) checkbox_widget = QWidget() checkbox_widget.setLayout(self.checkboxes_layout) scroll.setWidget(checkbox_widget) scroll.setMinimumHeight(200) layout.addWidget(QLabel("选择计划(可多选):")) layout.addWidget(scroll) person_row = QHBoxLayout() self.btn_import_persons = QPushButton("📥 导入人员名单") self.btn_import_persons.clicked.connect(self.import_personnel) person_row.addWidget(self.btn_import_persons) person_row.addStretch() layout.addLayout(person_row) self.person_tip = QLabel("尚未导入人员名单") self.person_tip.setStyleSheet("color: #888; font-size: 12px;") layout.addWidget(self.person_tip) btn_layout = QHBoxLayout() download_btn = QPushButton("下载导入模板") import_btn = QPushButton("导入新计划") manage_persons_btn = QPushButton("👥 管理本班人员") save_btn = QPushButton("保存并返回") download_btn.clicked.connect(self.download_template) import_btn.clicked.connect(self.import_new_plan) manage_persons_btn.clicked.connect(self.manage_personnel) save_btn.clicked.connect(self.save_and_return) btn_layout.addWidget(download_btn) btn_layout.addWidget(import_btn) btn_layout.addWidget(manage_persons_btn) btn_layout.addStretch() btn_layout.addWidget(save_btn) layout.addLayout(btn_layout) self.setLayout(layout) self.refresh_checkboxes() def refresh_checkboxes(self): while self.checkboxes_layout.count(): child = self.checkboxes_layout.takeAt(0) if child.widget(): child.widget().deleteLater() plans = load_plans() if not plans: empty_label = QLabel("暂无可用计划,请先导入。") empty_label.setStyleSheet("color: gray;") self.checkboxes_layout.addWidget(empty_label) else: for name in sorted(plans.keys()): cb = QCheckBox(name) cb.toggled.connect(lambda checked, n=name: self.on_plan_toggled(n, checked)) self.checkboxes_layout.addWidget(cb) def on_plan_toggled(self, name, checked): if checked: self.selected_plans.add(name) else: self.selected_plans.discard(name) def download_template(self): save_path, _ = QFileDialog.getSaveFileName( self, "保存模板文件", TEMPLATE_FILE, "Excel Files (*.xlsx)" ) if save_path: try: df = pd.DataFrame(columns=['岗位', '流程', '风险', '标准', '注意事项']) with pd.ExcelWriter(save_path, engine='openpyxl') as writer: df.to_excel(writer, index=False, sheet_name="模板") QMessageBox.information(self, "成功", f"模板已保存至:\n{save_path}") except Exception as e: QMessageBox.critical(self, "失败", f"无法保存模板:{e}") def import_new_plan(self): file_path, _ = QFileDialog.getOpenFileName( self, "选择要导入的 Excel 文件", "", "Excel Files (*.xlsx)" ) if not file_path: return try: df = pd.read_excel(file_path, engine='openpyxl') required_cols = ['岗位', '流程', '风险', '标准', '注意事项'] actual_cols = list(df.columns)[:5] if actual_cols != required_cols: raise ValueError(f"列名不匹配!期望:{required_cols},实际:{actual_cols}") df = df[required_cols].fillna("") plan_name = os.path.splitext(os.path.basename(file_path))[0] plan_data = {} for _, row in df.iterrows(): post = str(row['岗位']).strip() if not post: continue plan_data[post] = { '流程': str(row['流程']), '风险': str(row['风险']), '标准': str(row['标准']), '注意事项': str(row['注意事项']) } save_plan(plan_name, plan_data) QMessageBox.information(self, "成功", f"计划 '{plan_name}' 导入成功!") self.refresh_checkboxes() except Exception as e: QMessageBox.critical(self, "导入失败", f"错误:\n{str(e)}") def import_personnel(self): current_class = self.class_combo.currentText().strip() if not current_class: QMessageBox.warning(self, "警告", "请先选择一个班级!") return file_path, _ = QFileDialog.getOpenFileName( self, "选择人员名单文件", "", "Excel Files (*.xlsx);;All Files (*)" ) if not file_path: return try: df = pd.read_excel(file_path) if '姓名' not in df.columns: QMessageBox.critical(self, "错误", "Excel 文件必须包含 '姓名' 列!") return name_col = df['姓名'].astype(str) post_col = df.get('岗位', ["普通成员"] * len(df)).astype(str) imported_data = [{"name": n.strip(), "post": p.strip()} for n, p in zip(name_col, post_col) if n.strip()] if not imported_data: QMessageBox.warning(self, "提示", "未导入任何有效人员。") return all_data = {} if os.path.exists(PERSONNEL_FILE): with open(PERSONNEL_FILE, 'r', encoding='utf-8') as f: content = f.read().strip() if content: all_data = json.loads(content) all_data[current_class] = imported_data with open(PERSONNEL_FILE, 'w', encoding='utf-8') as f: json.dump(all_data, f, ensure_ascii=False, indent=2) QMessageBox.information(self, "导入成功", f"✅ 已导入 {len(imported_data)} 名成员到【{current_class}】") except Exception as e: QMessageBox.critical(self, "导入失败", f"无法读取文件或保存数据:\n{str(e)}") def manage_personnel(self): dialog = PersonManagementDialog(self.class_combo.currentText(), self) dialog.exec() def save_and_return(self): self.class_name = self.class_combo.currentText() self.date_str = self.date_edit.date().toString("yyyy-MM-dd") selected_names = list(self.selected_plans) if not selected_names: QMessageBox.warning(self, "警告", "请至少选择一个计划!") return personnel = [] if os.path.exists(PERSONNEL_FILE): with open(PERSONNEL_FILE, 'r', encoding='utf-8') as f: data = json.load(f) personnel = data.get(self.class_name, []) save_class_selection(self.class_name, self.date_str, selected_names, personnel) QMessageBox.information(self, "保存成功", f"已为【{self.class_name}】保存值班安排。") self.close() # ========== 职责卡生成窗口 ========== class DutyCardWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("职责卡生成") self.resize(600, 500) self.init_ui() def init_ui(self): layout = QVBoxLayout() layout.setContentsMargins(30, 30, 30, 30) title = QLabel("职责卡生成") title.setAlignment(Qt.AlignCenter) title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 20px;") layout.addWidget(title) class_layout = QHBoxLayout() class_layout.addWidget(QLabel("选择班级:")) self.class_combo = QComboBox() self.class_combo.addItems(["一班", "班", "三班", "四班", "五班"]) class_layout.addWidget(self.class_combo) btn_load = QPushButton("加载岗位") btn_load.clicked.connect(self.load_posts) class_layout.addWidget(btn_load) layout.addLayout(class_layout) self.posts_layout = QVBoxLayout() scroll = QScrollArea() scroll.setWidgetResizable(True) widget = QWidget() widget.setLayout(self.posts_layout) scroll.setWidget(widget) scroll.setMinimumHeight(300) layout.addWidget(QLabel("涉及岗位:")) layout.addWidget(scroll) self.setLayout(layout) def load_posts(self): class_name = self.class_combo.currentText().strip() print(f"[调试] 开始加载班级: '{class_name}' 的岗位") # 1. 获取选择记录 selection = get_last_selection_by_class(class_name) if not selection: QMessageBox.warning(self, "未找到记录", f"⚠️ 班级【{class_name}】无值班安排!") return # 清空旧按钮 while self.posts_layout.count(): child = self.posts_layout.takeAt(0) if child.widget(): child.widget().deleteLater() # 2. 加载所有计划 plans = load_plans() selected_plan_names = selection.get("plans", []) all_duties = {} print(f"[调试] 正在合并这些计划: {selected_plan_names}") for plan_name in selected_plan_names: if plan_name not in plans: print(f"[错误] 找不到计划: {plan_name}") continue plan_data = plans[plan_name] print(f"[调试] 计划 '{plan_name}' 包含岗位: {list(plan_data.keys())}") for post, duty in plan_data.items(): clean_post = post.strip() if clean_post: all_duties[clean_post] = duty if not all_duties: no_label = QLabel("❌ 无有效岗位数据") no_label.setStyleSheet("color: gray;") self.posts_layout.addWidget(no_label) return # 3. 获取人员 personnel_list = selection.get("personnel", []) print(f"[调试] 本班人员共 {len(personnel_list)} 人: {personnel_list}") # 4. 构建按钮数据快照(关键:固化每一项) button_specs = [] for post_name in sorted(all_duties.keys()): clean_post = post_name.strip() # ✅ 严格匹配岗位名(注意 .get 和 strip) matched_people = [ p for p in personnel_list if p.get("post", "").strip() == clean_post ] print(f"[调试] 岗位 '{clean_post}' → 匹配到 {len(matched_people)} 人") # ✅ 把当前所有数据打包成字典,避免后续污染 spec = { "display_name": clean_post, "duty": dict(all_duties[post_name]), # 复制一份 "people": [dict(p) for p in matched_people] } button_specs.append(spec) # 5. 创建按钮并绑定各自的数据副本 for spec in button_specs: name = spec["display_name"] count = len(spec["people"]) btn = QPushButton(f"👥 {name} ({count})") btn.setMinimumHeight(45) btn.setStyleSheet(""" text-align: left; padding-left: 20px; font-size: 14px; background-color: #f0f8ff; border: 1px solid #ccc; border-radius: 8px; """) # ✅ 使用 partial 安全传参(每个 spec 是独立对象) btn.clicked.connect( lambda _, n=name, d=spec["duty"], p=spec["people"]: self.show_duty_with_people(n, d, p) ) self.posts_layout.addWidget(btn) print(f"[完成] 成功创建 {len(button_specs)} 个按钮") def show_duty_with_people(self, post_name, duty_data, people): dialog = QDialog(self) dialog.setWindowTitle(f"岗位职责:{post_name}") dialog.resize(600, 500) layout = QVBoxLayout() layout.setContentsMargins(20, 20, 20, 20) # 标题 title = QLabel(f"📌 {post_name}") title.setStyleSheet("font-size: 18px; font-weight: bold; margin-bottom: 10px;") layout.addWidget(title) # 岗位详情表格区 detail_frame = QFrame() detail_frame.setFrameShape(QFrame.Box) detail_layout = QGridLayout(detail_frame) detail_layout.setSpacing(10) detail_layout.setContentsMargins(15, 15, 15, 15) labels = ["流程", "风险", "标准", "注意事项"] for i, key in enumerate(labels): lbl = QLabel(key + ":") lbl.setStyleSheet("font-weight: bold; padding: 5px 0;") val = QLabel(duty_data.get(key, "暂无")) val.setWordWrap(True) val.setStyleSheet("color: #333;") detail_layout.addWidget(lbl, i, 0) detail_layout.addWidget(val, i, 1) layout.addWidget(detail_frame) # 人员标题 people_label = QLabel(f"本岗位人员 ({len(people)}人):") people_label.setStyleSheet("font-weight: bold; margin-top: 15px; margin-bottom: 5px;") layout.addWidget(people_label) # 人员滚动区域(始终保留) people_scroll = QScrollArea() people_scroll.setWidgetResizable(True) people_scroll.setMinimumHeight(120) people_scroll.setMaximumHeight(120) people_scroll.setStyleSheet(""" QScrollArea { border: 1px solid #ddd; border-radius: 8px; } QScrollBar::vertical { width: 12px; background: #f0f0f0; } QScrollBar::handle:vertical { background: #ccc; border-radius: 6px; } """) # 内部容器 people_container = QWidget() people_inner_layout = QHBoxLayout(people_container) people_inner_layout.setContentsMargins(10, 10, 10, 10) people_inner_layout.setSpacing(15) people_inner_layout.setAlignment(Qt.AlignLeft) # 显示人员卡片或占位提示 if people and isinstance(people, list): for person in people: name = person.get("name", "未知姓名") card = self.create_person_card(name) people_inner_layout.addWidget(card) else: # 即使没人也保留区域,显示提示文字 placeholder = QLabel("⚠️ 暂无人员分配至该岗位") placeholder.setStyleSheet("color: #aaa; font-style: italic; padding: 30px;") placeholder.setAlignment(Qt.AlignCenter) people_inner_layout.addWidget(placeholder) people_scroll.setWidget(people_container) layout.addWidget(people_scroll) # 对话框按钮(可选) btn_layout = QHBoxLayout() btn_close = QPushButton("关闭") btn_close.clicked.connect(dialog.accept) btn_layout.addStretch() btn_layout.addWidget(btn_close) layout.addLayout(btn_layout) dialog.setLayout(layout) dialog.exec() def create_person_card(self, name): card = QFrame() card.setFrameShape(QFrame.Box) card.setStyleSheet(""" QFrame { border: 1px solid #ccc; border-radius: 8px; padding: 8px; background: white; } """) layout = QVBoxLayout(card) layout.setContentsMargins(4, 4, 4, 4) avatar = QLabel() avatar.setFixedSize(60, 60) avatar.setStyleSheet("border-radius: 30px; background-color: #eee; border: 1px solid #ddd;") found = False for ext in ['.jpg', '.png', '.jpeg', '.gif']: path = os.path.join(PHOTO_DIR, f"{name}{ext}") if os.path.exists(path): pixmap = QPixmap(path).scaled(60, 60, Qt.KeepAspectRatio, Qt.SmoothTransformation) avatar.setPixmap(pixmap) found = True break if not found: avatar.setText("👤") avatar.setStyleSheet("font-size: 20px;") name_label = QLabel(name) name_label.setAlignment(Qt.AlignCenter) name_label.setStyleSheet("font-size: 12px; color: #333;") layout.addWidget(avatar) layout.addWidget(name_label) return card # ========== 主窗口 ========== class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("岗位职责管理系统") self.resize(400, 300) self.init_ui() def init_ui(self): central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout() layout.setContentsMargins(50, 50, 50, 50) layout.setSpacing(30) btn1 = QPushButton("计划选择") btn2 = QPushButton("职责卡生成") for btn in [btn1, btn2]: btn.setMinimumHeight(80) btn.setFont(QFont("SimHei", 14)) btn.setStyleSheet(""" QPushButton { background-color: #4A90E2; color: white; border-radius: 10px; font-size: 16px; } QPushButton:hover { background-color: #357ABD; } """) btn1.clicked.connect(self.open_plan_selection) btn2.clicked.connect(self.open_duty_card) layout.addWidget(btn1) layout.addWidget(btn2) layout.addStretch() central_widget.setLayout(layout) def open_plan_selection(self): self.plan_win = PlanSelectionWindow() self.plan_win.show() def open_duty_card(self): self.duty_win = DutyCardWindow() self.duty_win.show() # ========== 启动应用 ========== if __name__ == "__main__": app = QApplication(sys.argv) app.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) window = MainWindow() window.show() sys.exit(app.exec()) 还是不行,直接帮我修改吧
最新发布
12-06
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值