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

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

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

    与 DOS 和 Win9x 环境不同,NT 的用户态程序是在一个严格受限的环境下运行,因此一些特殊资源如端口的访问就不能直接暴露给用户,避免发生冲突或对系统稳定性造成影响。如我们所熟知的端 口操作指令 IN/OUT 等就被归为特权指令,在用户态程序调用可能会引发异常。
    这一限制在实现上是通过两层机制完成的: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 结构(ntos/inc/i386.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 偏移为 20ac, 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 偏移和内容做文章。

    此外一些文章也使用到相同原理,如《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 20ab
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 函数(ntos/ke/i386/iopm.c:80)和 Ke386QueryIoAccessMap 函数(ntos/ke/i386/iopm.c:235)就是系统提供用来读写这两个 IOPM 表的函数。而 Ke386IoSetAccessProcess 函数(ntos/ke/i386/iopm.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 的源码,这里就不在罗嗦了。

参考文献:

1.Intel Architecture Sofware Developer's Manual

2.Direct Port I/O and Windows NT, Dale Roberts
  http://www.ddj.com/articles/1996/9605/

3.PortTalk - A Windows NT I/O Port Device Driver, Craig Peacock
  http://www.beyondlogic.org/porttalk/porttalk.htm

  PortTalk - 用于Windows NT/2000的端口驱动程序, 宋永强(翻译)
  http://www.daqchina.net/daqchina/acquire/ioaccess.htm?curtime=1084629289

4.NT下所有RING 3进程任意端口I/O, sinister (摘抄)
  http://www.xfocus.net/articles/200303/496.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值