CSDN博客

img flier_lu

NT 环境下用户态直接读写端口原理浅析

发表于2004/7/7 20:06:00  1416人阅读

http://www.blogcn.com/user8/flier_lu/index.html?id=1957096

   关于 NT 环境下用户态直接读写端口这码子事,本应该是95-96年 NT 架构刚刚出来时讨论的东西,现在翻出来炒现饭,实在是不得已的事情。因为前几天有朋友问起 TSS 中 IOPM 表的问题,而网上这方面的可用文章大多只是泛泛而谈,空有实现方法没有原理分析,没办法直接引用。而这些文章追述其源头基本上都是从 Dale Roberts 在 96 年 5 月发表在 Dr. Dobb's Journal 上的 Direct Port I/O and Windows NT 一文转述而来,可惜这篇文章要会员权限才能看,我等没有美刀的只能自己动手丰衣足食了。
     至于基于此原理的应用文章,网上随处可见。如有人对其做了个简单的封装 PortTalk,就足以满足大部分需求:

     PortTalk - A Windows NT I/O Port Device Driver
     PortTalk - 用于Windows NT/2000的端口驱动程序(翻译)

     以下将就其实现原理结合 NT 源码进行分析,了解相关功能可以怎样实现,以及为什么要这样实现。

     与 DOS 和 Win9x 环境不同,NT 的用户态程序是在一个严格受限的环境下运行,因此一些特殊资源如端口的访问就不能直接暴露给用户,避免发生冲突或对系统稳定性造成影响。如我们所熟知的端口操作指令 IN/OUT 等就被归为特权指令,在用户态程序调用可能会引发异常。

 screen.width/2)this.width=screen.width/2" vspace=2 border=0>

     这一限制在实现上是通过两层机制完成的:EFLAGS 标志寄存器中的 IOPL (I/O privilege level) 标志位和 TSS (Task State Segment ) 中的 IOPM (I/O permission bit map) 提供了灵活的两级控制机制。(Intel Architecture Sofware Developer's Manual V1: 12.5)
     我们知道 x86 架构下特权一般分为 0-3 四环,而 NT 环境用到的只是核心态 ring 0 和用户态 ring 3。EFLAGS 标志寄存器通过 IOPL 标志指定当前 Task 中哪些特权级别可以使用 I/O 指令。这个标志由 EFLAGS 寄存器的第 12/13 位保存,能够使用 I/O 指令的特权级别必须小于等于 IOPL 的当前值。而此标志位一般设置为 0,并只能通过 POPF 和 IRET 指令在 ring 0 进行修改(ring 3下修改不会发生异常,但没有效果)。这就保障内核能够完全限定用户态程序不能直接使用 I/O 等特权指令,但又能够通过马上要讨论的 IOPM 网开一面。(受到 IOPL 约束的 I/O sensitive 指令包括:IN, INS, OUT, OUTS, CLI, STI)
     如果当前特权级 CPL (Current Privilege Level) 大于 IOPL,则系统会进一步根据 TSS 中的 IOPM 判断是否特例允许对此端口的访问。TSS 是每任务相关的状态存储区,保存了状态 (Context) 切换所需的基本信息,如通用寄存器(EAX, ESP等等)、段选择子(CS, DS等等)、EFLAGS、EIP等可能动态改变的内容,还包括 CR3, LDT, IOPM 等静态内容。Intel 手册上定义 TSS 的最小长度为 104 字节,末尾的一个 WORD 就是 IOPM 相对于 TSS 的偏移。而操作系统一般来说对 TSS 都做了一定程度的定制,如 NT 架构下 TSS 结构(ntosinci386.h:879)大致如下:
 

以下为引用:

 typedef struct _KTSS
 {
   USHORT  Backlink;

   //...

   USHORT  Flags;

   USHORT  IoMapBase;

   KIIO_ACCESS_MAP IoMaps[IOPM_COUNT];

   KINT_DIRECTION_MAP IntDirectionMap;
 } KTSS, *PKTSS;
 



     因为当前任务的 TSS 有一个单独的 TR (Task Register) 寄存器(Intel Architecture Sofware Developer's Manual V3: 6.2.3)保存其16位段选择子和32位基址偏移,故而系统对I/O指令的处理伪代码可以表述如下:
 

以下为引用:

 typedef struct {
  unsigned limit : 16;
  unsigned baselo : 16;
  unsigned basemid : 8;
  unsigned type : 4;
  unsigned system : 1;
  unsigned dpl : 2;
  unsigned present : 1;
  unsigned limithi : 4;
  unsigned available : 1;
  unsigned zero : 1;
  unsigned size : 1;
  unsigned granularity : 1;
  unsigned basehi : 8;
 } GDTENT;

 typedef struct {
  unsigned short limit;
  GDTENT *base;
 } GDTREG;

 bool CheckIOPermission(WORD port)
 {
   if(CPL <= EFLAGS.IOPL) return true;

   GDTREG GdtReg;
   WORD TaskSeg;

   _asm cli;         // 禁止中断
   _asm sgdt GdtReg; // 获取 GDT 地址
   _asm str TaskSeg; // 获取 TSS 选择子索引

   GDTENT *pTaskGdt = GdtReg.base + (TaskSeg >> 3); // 获取 TSS 描述符地址

   KTSS *pTSS = (PVOID)(pTaskGdt->baselo | (pTaskGdt->basemid << 16) | (pTaskGdt->basehi << 24)); // 计算 TSS 基址

   char *pIOPM = ((char *)pTSS + pTSS->IoMapBase); // 计算 IOPM 基址

   size_t pos = port >> 3, idx = port & 0xF;

   if((pIOPM + pos) > (pTSS + TaskGdt->limit))
   {
     throw GeneralProtectionException();
   }

   _asm sti;

   return (pIOPM[pos] & (1 << idx)) == (1 << idx);
 }
 



     首先系统检测当前特权级别 CPL 是否小于 EFLAGS 的 IOPL;然后从 TR 寄存器中获取 TSS 选择子索引,并计算得到 TSS 描述符地址;通过 TSS 的基址和 IOPM 偏移可以得到 IOPM 地址;最后根据端口查询 IOPM 内容,判断是否允许对此端口进行操作。
     我们可以使用 windbg + livekd 工具实际看看一个系统中的相关情况:
 

以下为引用:

 // 显示 PCR
 kd> !pcr
 KPCR for Processor 0 at ffdff000:
     Major 1 Minor 1
  NtTib.ExceptionList: f460fbfc
      NtTib.StackBase: 00000000
     NtTib.StackLimit: 00000000
   NtTib.SubSystemTib: 80042000
        NtTib.Version: 2568915f
    NtTib.UserPointer: 00000001
        NtTib.SelfTib: 7ffdd000

              SelfPcr: ffdff000
                 Prcb: ffdff120
                 Irql: 00000000
                  IRR: 00000000
                  IDR: ffffffff
        InterruptMode: 00000000
                  IDT: 8003f400
                  GDT: 8003f000
                  TSS: 80042000

        CurrentThread: 826a7788
           NextThread: 00000000
           IdleThread: 80569280

            DpcQueue:

 // 显示 TSS
 kd> dd 80042000
 80042000  eb3d76f6 f460fde0 0d8b0010 00441f30
 80042010  0674c085 24f8448b 048b03eb 33026a0b
 80042020  e85051c9 fffffd6c 1474c085 50413881
 80042030  08744349 04c38347 c972fe3b 0272fe3b
 80042040  5e5fc033 8b55c35b 8b5151ec 008b0845
 80042050  4453523d f8458954 30a10a75 e900441f

 80042060  00000000 20ac0000 18000004 00000018 // IOPM 偏移, KTSS.IoMapBase

 80042070  00000000 00000000 00000000 00000000
 80042080  00000000 00000000 ffffffff ffffffff // TSS 内置 IOPM, KTSS.IoMaps[0]
 80042090  ffffffff ffffffff ffffffff ffffffff
 ...
 80044080  ffffffff ffffffff ffffffff 18000004
 80044090  00000018 00000000 00000000 00000000
 800440a0  00000000 00000000 00000000 cbb70fd9
 800440b0  75ff5051 fc4d890c 0009e6e8 06896600
 



     可以看到 TSS 的内容保存在 0x80042000 处;其 0x64 偏移内容 0x20ac 是当前 IOPM 的偏移;而 0x88 偏移处的一堆 0xFFFFFFFF 是 KTSS.IoMaps[0] 的内容,此 IOPM 表内容等会再详细解析;而 0x20ac 处正是实际使用的 IOPM 内容。

     基于此原理,Dale Roberts 提出了几种实现允许用户模式访问端口的方法,归根结底都是对 TSS 的 IOPM 偏移和内容做文章。

     有篇文章很详细的讨论了这个尝试过程,可惜是俄文的。找了几个翻译软件,最后发现还是 www.freetranslation.com提供的在线翻译比较好使,先翻译成英文再看,呵呵。

     此外一些文章也使用到相同原理,如 《NT下所有RING 3进程任意端口I/O》 一文。值得注意的是这里原文选择将 TSS 长度限制增加 0xF00,实际上限制了能够自由访问端口必须是小于 0xF00 * 8 = 30720。使用这种方法时,应该考虑到这种硬性的限制。而 0xF00 的限制,是为了保证对 TSS 长度的扩展,不会导致页错误。因为原有 TSS 长度一般是 0x20ab,在增加 0xF00 后不会导致跨页问题。TotalIO.c中对此问题描述如下:
 

以下为引用:

   Since we can safely extend the TSS only to the end of the physical memory page in which it lies, the I/O access is granted only up to port 0xf00.  Accesses beyond this port address will still generate exceptions.
 



     在实际环境中查看 TSS 的 GDT 表项方法如下(0x28 >> 3 = 5):
 

以下为引用:

 kd> rm 0x100
 Last set context
 kd> r
 Last set context:
 gdtr=8003f000   gdtl=03ff idtr=8003f400   idtl=07ff tr=0028  ldtr=0000

 kd> dd 8003f000
 8003f000  00000000 00000000 0000ffff 00cf9b00
 8003f010  0000ffff 00cf9300 0000ffff 00cffb00
 8003f020  0000ffff 00cff300 200020ab 80008b04 // TSS Limit
 8003f030  f0000001 ffc093df d0000fff 7f40f3fd
 



     TotalIO.c 中设置 TSS 长度限制的完整代码如下:
 

以下为引用:

 void SetTSSLimit(int size)
 {
  GDTREG gdtreg;
  GDTENT *g;
  short TaskSeg;

  _asm cli;       // don't get interrupted!
  _asm sgdt gdtreg;     // get GDT address
  _asm str TaskSeg;     // get TSS selector index
  g = gdtreg.base + (TaskSeg >> 3); // get ptr to TSS descriptor
  g->limit = size;     // modify TSS segment limit
 //
 //  MUST set selector type field to 9, to indicate the task is
 // NOT BUSY.  Otherwise the LTR instruction causes a fault.
 //
  g->type = 9;      // mark TSS as "not busy"
 //
 //  We must do a load of the Task register, else the processor
 // never sees the new TSS selector limit.
 //
  _asm ltr TaskSeg;     // reload task register (TR)
  _asm sti;       // let interrupts continue
 }
 



     这里把 TSS 的 type 设置为 9,表示此描述符类型为32位TSS非Busy描述符(Intel Architecture Sofware Developer's Manual V3: 3.5)。
     这种方法通过直接操作系统寄存器相关内容达到对系统任意进程受限端口的允许访问,但并不是一个完美的解决方案。相对来说通过操作系统未公开函数 Ke386SetIoAccessMap, Ke386QueryIoAccessMap 和 Ke386IoSetAccessProcess 实现独立进程的特殊端口允许访问的方法更加优雅一些。下面我们来仔细看看这几个函数的原理和使用。
     通过前面对 NT 系统中 KTSS 结构和实际内存的分析,我们可以了解:NT 环境下,每个进程单独维护了一个 TSS 内存区域,其中由 TSS 内部维护了一个全部标志位置 1 的 IOPM 表,在 TSS 末尾还维护了另外一个实际中承担端口管理工作的 IOPM 表。Ke386SetIoAccessMap 函数(ntoskei386iopm.c:80)和 Ke386QueryIoAccessMap 函数(ntoskei386iopm.c:235)就是系统提供用来读写这两个 IOPM 表的函数。而 Ke386IoSetAccessProcess 函数(ntoskei386iopm.c:318)则指定进程到底使用哪个 IOPM 表。
 

以下为引用:

 BOOLEAN Ke386QueryIoAccessMap(ULONG MapNumber, PKIO_ACCESS_MAP IoAccessMap);
 BOOLEAN Ke386SetIoAccessMap(ULONG MapNumber, PKIO_ACCESS_MAP IoAccessMap);

 BOOLEAN Ke386IoSetAccessProcess(PKPROCESS Process, ULONG MapNumber);
 



     对前两个函数来说,MapNumber指定要对哪个表进行操作。系统定义了一个 IO_ACCESS_MAP_NONE = 0 常量表示在 TSS 后面那个真实 IOPM 表,而其他的索引对应于 KTSS.IoMaps[] 数组。此数组大多数情况下只有一个表项,也就是说 MapNumber 为 0 时表示 TSS 后面那个 IOPM;为 1 时表示 TSS 内部的 KTSS.IoMaps[0]。
     Ke386QueryIoAccessMap 函数只是简单的根据 MapNumber 判断是将 IoAccessMap 内容全部置位(MapNumber = 0)、还是从 TSS 中复制对应的表 (0 < MapNumber <= IOPM_COUNT = 1)。伪代码如下:
 

以下为引用:

 #define IOPM_COUNT          1
 #define IOPM_SIZE           8192    // Size of map callers can set.

 BOOLEAN Ke386QueryIoAccessMap(ULONG MapNumber, PKIO_ACCESS_MAP IoAccessMap)
 {
   if(MapNumber > IOPM_COUNT) return FALSE;

   if(MapNumber == IO_ACCESS_MAP_NONE)
   {
     memset(IoAccessMap, -1, IOPM_SIZE);
   }
   else
   {
     void *pIOPM = &(KiPcr()->TSS->IoMaps[MapNumber-1].IoMap);

     memcpy(IoAccessMap, pIOPM, IOPM_SIZE);
   }
   return TRUE;
 }
 



     而 Ke386SetIoAccessMap 在 MapNumber 为 0 时直接返回 FALSE,因为 TSS 后的那个表是不允许修改的;对其他情况,函数将 IoAccessMap 中的内容复制回 TSS 的 IOPM 表中,并在多处理器情况下通知其他处理器重新载入 IOPM 表。伪代码如下:
 

以下为引用:

 BOOLEAN Ke386SetIoAccessMap(ULONG MapNumber, PKIO_ACCESS_MAP IoAccessMap)
 {
   if((MapNumber > IOPM_COUNT) || (MapNumber == IO_ACCESS_MAP_NONE)) return FALSE;

   void *pIOPM = &(KiPcr()->TSS->IoMaps[MapNumber-1].IoMap);

   memcpy(pIOPM, IoAccessMap, IOPM_SIZE);

   KiPcr()->TSS->IoMapBase = GetCurrentProcess()->IopmOffset;

   // 通知其他处理器重设 IOPM

   return TRUE;
 }
 



     Ke386IoSetAccessProcess 函数则简单地修改当前 TSS 的 IOPM 偏移为 MapNumber 指定的 IOPM 表偏移,并在多 CPU 情况下通知其他 CPU 重新载入 IOPM 偏移。计算偏移算法如下:
 

以下为引用:

 #define KiComputeIopmOffset(MapNumber)          
     (MapNumber == IO_ACCESS_MAP_NONE) ?         
         (USHORT)(sizeof(KTSS)) :                    
         (USHORT)(FIELD_OFFSET(KTSS, IoMaps[MapNumber-1].IoMap))

 USHORT MapOffset = KiComputeIopmOffset(MapNumber);
 



     完整的使用流程代码如下:
 

以下为引用:

 #define IOPM_SIZE           8192    // Size of map callers can set.

 typedef UCHAR   KIO_ACCESS_MAP[IOPM_SIZE];
 typedef KIO_ACCESS_MAP *PKIO_ACCESS_MAP;

 PKIO_ACCESS_MAP IOPM_local = MmAllocateNonCachedMemory(sizeof(IOPM));
 if(IOPM_local == 0)
  return STATUS_INSUFFICIENT_RESOURCES;

 Ke386QueryIoAccessMap(1, IOPM_local);

 // 修改 IOPM_Local 内容打开需要使用的端口

 Ke386SetIoAccessMap(1, IOPM_local);
 Ke386IoSetAccessProcess(PsGetCurrentProcess(), 1);
 



     具体代码可以参考 PortTalk 和 TotalIO 的源码,这里就不在罗嗦了。

0 0

相关博文

我的热门文章

img
取 消
img