这个80286检测码是怎么工作的?

逆向工程 拆卸 x86 dos-exe
2021-06-20 04:57:33

我正在浏览大约 1992 年的一个反汇编的 16 位 DOS 游戏。原始系统要求指出该游戏需要 IBM AT 兼容机器或更高版本的 286 处理器才能运行。并且有一个存根main()检查处理器并在未找到时显示错误消息。

我对实际测试的内容很感兴趣,我追踪了似乎是测试程序的内容。它由五个有条件地运行的子测试组成,它根据测试结果返回 0..7 范围内的整数。我大致弄清楚了代码的作用(尽管可能存在错误;我仍然缺乏经验,有时会误读/误解指令序列的含义)。

; ... stack setup omitted ...
pushfw

; ==========================================
; === CHECK #1 =============================
; ==========================================
; Sets FLAGS to 0x0 and then immediately reads it back. On an 8086/80186, bits
; 12-15 always come back set. On a 80286+ this is not the case.
; 8086/80186 behavior: jump to check 3.
; 80286+ behavior: fall through to check 2.
xor ax,ax      ; AX=0x0
push ax
popfw          ; pop 0x0 into FLAGS
pushfw
pop ax         ; pop FLAGS into AX

and ax,0xf000  ; bits 12-13: IOPL, always 1 on 86/186
cmp ax,0xf000  ; bit 14: NT, always 1 on 86/186
               ; bit 15: Reserved, always 1 on 86/186, always 0 on 286+
jz check3

; ==========================================
; === CHECK #2 =============================
; ==========================================
; Only runs if CPU is plausibly an 80286. Last check before returning.
; Sets DL=0x6 if IOPL and NT flag bits are all clear.
; Sets DL=0x7 if any bits in IOPL/NT flags are set.
mov dl,0x6     ; DL is the proc's return val
mov ax,0x7000
push ax
popfw          ; pop 0x7000 into FLAGS
pushfw
pop ax         ; pop FLAGS into AX

and ax,0x7000  ; bits 12-13: IOPL
               ; bit 14: NT
jz done
inc dl         ; DL=0x7 if any bit was set
jmp done
nop

; ==========================================
; === CHECK #3 =============================
; ==========================================
; Only runs if CPU seems to be an 8086/80186.
; Sets DL=0x4 and moves on to...
;   check 4 if 0xff >> 21 == 0
;   check 5 otherwise (how can this happen?)
check3:
mov dl,0x4     ; DL is the proc's return val
mov al,0xff
mov cl,0x21
shr al,cl      ; AL = 0xff >> 0x21
jnz check5     ; when does this happen?

; ==========================================
; === CHECK #4 =============================
; ==========================================
; At this point, DF is still 0. ES doesn't
; point to anything sensible.
; Sets DL=0x2 if the loop completes.
; Sets DL=0x0 if the loop does not complete.
; Moves onto check 5 unconditionally.
mov dl,0x2     ; DL is the proc's return val
sti            ; are interrupts important?
push si
mov si,0x0
mov cx,0xffff
rep lods [BYTE PTR es:si] ; read 64K, ES[SI]->AL, all junk?
pop si
or cx,cx       ; test if loop reached 0
jz check5
mov dl,0x0     ; didn't hit 0. interrupted?

; ==========================================
; === CHECK #5 =============================
; ==========================================
; Leaving memory addresses here because they seem important.
; Here, DL is either 0x0 or 0x2 from check 4, or 0x4 from check 3. Looks like,
; contingent on the INC instruction getting overwritten, DL either stays at
; 0x0/0x2/0x4, or becomes 0x1/0x3/0x5.
check5:
00000B74  push cs
00000B75  pop es        ; Set ES to CS. (why not mov es,cs? illegal?)
00000B76  std           ; DF=1, rep decrements CX
00000B77  mov di,0xb88
00000B7A  mov al,0xfb   ; is this just an STI opcode?
00000B7C  mov cx,0x3
00000B7F  cli           ; are interrupts undesired?
00000B80  rep stosb     ; write 3 bytes, AL->ES[DI]
00000B82  cld           ; DF=0, why does it matter now?
00000B83  nop
00000B84  nop
00000B85  nop
00000B86  inc dx        ; destination when CX=1. overwritten?
00000B87  nop           ; destination when CX=2
00000B88  sti           ; destination when CX=3

done:
popfw
xor dh,dh      ; only keep low bits
mov ax,dx      ; return through AX
; ... stack teardown omitted ...
retf

; Return values:
; AX == 0x0: 8086, normal right-shift, loop aborted, overwrites
; AX == 0x1: 8086, normal right-shift, loop aborted, did not overwrite
; AX == 0x2: 8086, normal right-shift, loop finished, overwrites
; AX == 0x3: 8086, normal right-shift, loop finished, did not overwrite
; AX == 0x4: 8086, weird right-shift, overwrites
; AX == 0x5: 8086, weird right-shift, did not overwrite
; AX == 0x6: 286, with clear IOPL/NT flags
; AX == 0x7: 286, with set IOPL/NT flags

这是我目前能想到的:

检查 1:看起来很简单。将 FLAGS 显式设置为 0x0,然后读回。8086 将强制所有位 12..15 为 1,而 286 不会。来源

检查 2:仅针对 286,似乎与检查 1 类似,但特别关注保护模式标志。不确定这对调用者有什么意义。

(顺便说一句:如果我们假设 CPU 是 286,它不能push 0x7000代替mov ax,0x7000; push ax吗?)

检查 3:计算0xff >> 0x21并查找除 之外的结果0这怎么会发生?非零结果是否有理由排除检查 4 的需要?

检查4:从ES读取64K到AL。看起来很忙;ES 没有被设置为任何有用的东西,也没有读取 AL。测试的核心似乎是围绕 CX 永远不会达到零的想法构建的,可能是因为循环期间某处的中断?不是应该中断程序iret并返回这里完成吗?

检查5:自修改代码?看起来它用 替换了测试的最后几条指令STI,从而删除了INC否则会影响返回值的 ?在什么情况下它不会覆盖,从而执行INC

(旁白:push cs; pop es可以改写为mov es,cs还是不是法律形式?)

我觉得我对它的理解还很远,但显然还有一些漏洞。我也不太熟悉 x86,所以我翻译的评论中也可能存在误解。我感觉到这里有一些真正的聪明之处,由非常详细地了解这些机器复杂性的人所写。如果可以的话,我想在某种程度上了解他们的魔法。

2个回答

我将深入探讨过去,并尝试解释您在软件中观察到的不同检查。我找到了三个解释行为的参考资料(如我所愿),我将在本答案中将其称为 /1/、/2/、/3/。

/1/ http://www.drdobbs.com/embedded-systems/processor-detection-schemes/184409011

这是一篇来自 DrDobbs 期刊的档案文章(很遗憾,多年来不再存在,但他们的档案仍然是宝贵的资源),由 Richard Leinecker 于 1993 年 6 月 1 日撰写,称为“处理器检测方案”。

/2/ https://github.com/lkundrak/dev86/blob/master/libc/misc/cputype.c

这是一个由 Robert de Bath 编写的程序,于 2013 年 10 月 23 日发布,也相当详细地涵盖了像这里的问题,不幸的是没有太多的代码注释。

/3/ iAPX 86/88、186/188 用户手册,程序员参考,英特尔,1983 年 5 月

它是标题中列出的处理器的英特尔程序员参考,在许多方面仍然有效(某些领域技术变化速度极快的一个很好的例子......)。

您的支票:

CHECK1:你自己给出了解释。它可以在 /1/, LISTING ONE(也包含在文章中)中得到验证。我不会在这里复制代码,也不会进一步评论,因为没有什么可以添加到您的解释中。

CHECK2:检查处理器是否为 286 或更高(如 386 或 486)。我将引用 /1/ 的描述及其代码。引用:

; Is It an 80286?
; Determines whether processor is a 286 or higher. Going into subroutine ax = 2
; If the processor is a 386 or higher, ax will be 3 before returning. The
; method is to set ax to 7000h which represent the 386/486 NT and IOPL bits
; This value is pushed onto the stack and popped into the flags (with popf).
; The flags are then pushed back onto the stack (with pushf). Only a 386 or 486
; will keep the 7000h bits set. If it's a 286, those bits aren't defined and
; when the flags are pushed onto stack these bits will be 0. Now, when ax is
; popped these bits can be checked. If they're set, we have a 386 or 486.
IsItA286    proc
        pushf               ; Preserve the flags
        mov ax,7000h        ; Set the NT and IOPL flag
                            ; bits only available for
                            ; 386 processors and above
        push    ax          ; push ax so we can pop 7000h
                            ; into the flag register
        popf                ; pop 7000h off of the stack
        pushf               ; push the flags back on
        pop ax              ; get the pushed flags
                            ; into ax
        and ah,70h          ; see if the NT and IOPL
                            ; flags are still set
        mov ax,2            ; set ax to the 286 value
        jz  YesItIsA286     ; If NT and IOPL not set
                            ; it's a 286
        inc ax              ; ax now is 4 to indicate
                            ; 386 or higher
YesItIsA286:
        popf                ; Restore the flags

        ret                 ; Return to caller
IsItA286    endp

我希望您能立即看到与您的代码的相似之处。

CHECK3:确定您是否拥有80186/80188或更早版本。引自 /3/,第 3-26 页,“SHIFTS”一章:

“在 8086,88 上最多可以执行 255 个班次。......

...在 80186 之前,188 执行移位(或旋转)它们与要移位的值与 1FH,从而将发生的移位次数限制为 32 位。”

你的代码,评论说:

mov dl, 0x4     ; DL is the proc's return val
mov al, 0xff    ; al contains 0xff
mov cl, 0x21    ; According to the above explanation from Intel,
                ; this value in cl is in an 80186/188 converted to 1, by ANDing with 0x1F.
shr al, cl      ; 80186/188 => al = 0x7F
                ; other: al = 0
jnz check5      ; goto check5 if you have an 80186/188

CHECK4:这个不是很清楚。不过,似乎是对一些CPU错误的测试。它似乎在测试 8086/88 的 CMOS 版本。

/2/ 列出以下带有注释的代码,从第 271ff 行开始:

; The CMOS 8088/6 had the bug with rep lods repaired.
cmos:   push si
    sti
    mov cx, #$FFFF
rep
    lodsb
    pop si
    or cx,cx
    jne test8
    mov bx,#2   ; Intel 80C88

这不完全是您的代码,但非常相似,因此我假设您的代码也针对 80C88 处理器进行了测试。我从来没有听说过这个错误,也没有在网上找到更多的信息。因此,有点猜测。

CHECK5 : 这一项测试我们是否拥有 8086/80186 或 8088/80188,即 16 位或 8 位机器。你的怀疑是对的,它是自修改代码。这个想法是自修改指令是否已经在预取队列中。此检查也包含在 /1/ 和 /2/ 中。我从/1/复制评论。

/1/中的作者是这样描述的:

“区分 8088s 和 8086s 比较棘手。我发现最简单的方法是修改 IP 前五个字节的代码。因为 8088 的预取队列是四个字节,而 8086 的预取队列是六个字节,IP 前面五个字节的指令第一次不会对 8086 产生任何影响。”

作为参考,英特尔在其手册 /3/, p.3-2 "Bus Interface Unit" 中写道:

“8088/188 结构队列最多可容纳四个字节的指令流,而 8086/80186 队列最多可存储六个指令字节。”

我不会在这里复制 /1/ 中的代码(非常相似),而是用一些我希望解释案例的注释重新注释您的代码。

check5:
00000B74  push cs
00000B75  pop es        ; Set ES to CS. (why not mov es,cs? /1/ uses mov ax, cs, mov es, ax)
00000B76  std           ; Cause stosb to count backwards (di is decremented)
00000B77  mov di,0xb88  ; di==offset of code tail to modify
00000B7A  mov al,0xfb   ; is this just an STI opcode? ;IMO yes, /1/ uses 0x90 al==nop instruction opcode
00000B7C  mov cx,0x3    ; Set for 3 repetitions
00000B7F  cli           ; are interrupts undesired? Yes, I remember having read somewhere (no quote though) 
                        ; that the next instruction can be interrupted, without the cli. 
                        ; This of course would spoil the trick.
00000B80  rep stosb     ; write 3 bytes, backwards from Addr 0xb88
                        ; !!! 5 bytes down is the critical instruction 
                        ; which will be either already in the queue (8086/186) or not (8088/188)
00000B82  cld           ; Clear the direction flag
00000B83  nop          ; Three nops in a row
00000B84  nop          ; provide dummy instructions
00000B85  nop
00000B86  inc dx        ; <<<=== This instruction is executed ONLY in the 8086/186 case. 
                        ; In the 80188/88 case, it is overwritten with STI
00000B87  nop           ; dummy instruction
00000B88  sti           

由于您的返回寄存器 dx 来自 CHECK3 的值为 4,因此在 CHECK5 之后,在 16 位情况下它的值为 5。

检查2:检查1测试标志字的高位是否可以清除,检查2测试是否可以设置在 80286 上,这些位不能在实模式下设置,而在 80386 上则可以。

检查 3:这是在测试处理器具有什么样的移位器。一些(较新的)有一个桶形移位器,可以有效地将移位计数掩盖到字长(并且使用 0x21 作为移位计数向我表明差异出现在后 80286 时代)。因此,移位 0x21 (33) 的结果与移位 33 - 32 = 1 的结果相同。我不知道桶形移位器出现在哪一代。

检查 4:我不记得细节,但我觉得其中的一部分很熟悉。它要么与最大长度循环后重复计数错误有关,要么与触发 CPU 错误的双指令前缀有关。我认为是后者,前缀的顺序很重要。当中断处理程序返回时,指令指针被设置到错误的地址,并且忘记了一个或多个前缀。插图:https : //www.youtube.com/watch?v=6FC-tcwMBnU请注意,您在此处的代码实际上首先具有 es: override 前缀,因此循环应始终完成!这可能是一个 CPU 错误检测例程,它本身包含一个错误吗?

检查 5:这是检查独立于任何数据缓存运行的指令缓存。在 80486 上,您可以踩踏处理器当前正在执行的 16 字节窗口,它仍将执行加载到(单独)指令缓存中的旧内容。我认为 Pentium+ 处理器会检测到这种覆盖并刷新指令缓存和预取队列。即使是最早的 x86 处理器也有足够长的预取队列(8088 除外)来覆盖被覆盖的指令。执行新代码的条件:在 Pentium+ (IIRC) 上,在单步调试器下,在 v86 模式下,其中 CLI 指令没有真正生效,并且会发生中断。