04内核-驱动对象


驱动对象

驱动程序:就是一个.sys模块,

驱动对象:则是.sys被加载到内核中的实例化出来的对象, 用于表示这个驱动模块.

Windows内核使用DRIVER_OBJECT结构体来描述一个驱动对象.

虽然Windows内核源码使用C语言编写, 但也使用了面向对象的思想. 在面向对象思想中, 有父类, 抽象类,纯虚函数的概念, 纯虚函数就是一个类中无须定义的虚函数. 拥有这个函数的类被称为抽象类, 继承抽象类的子类必须实现纯虚函数.

DRIVER_OBJECT这个结构体中, 它相当于一个类. 类中有几个字段是函数指针, 这些函数指针能够保存一些系统会调用的特定函数.

驱动对象就类似于一个类对象, 这个类对象直接被操作系统内核框架所操作, 当一个驱动对象要加载时, 系统会调用DriverEntry函数来构造此对象, 函数的第一个参数就是DRIVER_OBJECT* , 结构体指针, 这个结构体指针就类似于this指针(C语言没有this指针,此处只是类比), 在这个函数内, 需要编写代码来初始化驱动对象的一些必要字段

  • PDRIVER_INITIALIZE DriverInit - 驱动对象初始化函数指针, 此函数指针被I/O管理器初始化,指向DriverEntry函数

  • PDRIVER_STARTIO DriverStartIo - 驱动对象用于处理I/O的函数指针, 此函数指针可以被设置为NULL

  • PDRIVER_UNLOAD DriverUnload - 驱动对象的卸载函数指针, 当驱动被卸载时会被系统调用, 此函数指针必须被赋值,否则当驱动被卸载时,字段为NULL会引发系统蓝屏

  • PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1] - 驱动对象的派遣函数指针数组. 这个数组中,不同的下标保存着不同的函数, 下表列出了每个元素的下标(宏)所保存的函数指针 , (这个数组内的函数指针都可以设置为NULL):

名称
调用时机
触发调用的API

IRP_MJ_CLEANUP
当本驱动对象的设备对象句柄被关闭(且引用计数为0),此函数会被调用. 但仍有I/O请求未被完成 , 可以在此函数中清理未完成的I/O请求.
CloseHandle

IRP_MJ_CLOSE
当本驱动对象的设备对象句柄被关闭(且引用计数为0) , 并且I/O请求已经被完成或已经全部取消, 此函数会被调用. 此函数相当于设备对象的析构函数
CloseHandle

IRP_MJ_CREATE
当本驱动对象的设备对象被打开(通过CreateFile/ZwCreateFile) , 此函数会被调用. 此函数相当于设备对象的构造函数
CreateFile

IRP_MJ_DEVICE_CONTROL
设备控制, 用于读/写设备对象
DeviceIoControl

IRP_MJ_FILE_SYSTEM_CONTROL

IRP_MJ_FLUSH_BUFFERS
写输出缓冲区或者丢弃输入缓冲区
FlushFileBuffers

IRP_MJ_INTERNAL_DEVICE_CONTROL

IRP_MJ_PNP

IRP_MJ_POWER
电源管理器发出的的请求

IRP_MJ_QUERY_INFORMATION
获取设备对象的长度
GetFileSize

IRP_MJ_READ
读取设备对象的内容
ReadFile

IRP_MJ_SET_INFORMATION
设置设备对象的长度

IRP_MJ_SHUTDOWN

IRP_MJ_SYSTEM_CONTROL

IRP_MJ_WRITE
将数据写到设备对象
WriteFile

这些函数都是可选的, 当用户层或内核层通过设备的符号链接操作设备(打开,读,写,关闭)时, 这些函数就会被调用.

也就是说, 以前所使用的用户层API : CreateFile, GetFileSize , ReadFile , WriteFile ,CloseHandle这些API操作的是一个设备对象, 在内核中, 文件属于一个设备对象.

同一个函数,能够操作不同的对象, 这就是内核中通过C语言实现的多态了.

设备对象

设备对象一般是由驱动对象出来的(非即插即用驱动) . 驱动对象能够保存各种派遣函数, 但是, 这些派遣函数的一般是由I/O管理器所调用. 在调用派遣函数时, I/O管理器会将附加的信息打包到一个结构体,并传递给派遣函数. 一般这个结构体被称为IRP结构. 而只有设备对象才能接收到I/O管理器的I/O请求(什么是I/O请求? 比如, 当一个文件的设备对象被打开(CreateFile)之后, 对此文件的设备对象的读(ReadFile)和写(WriteFile)就是I/O请求) .

但是设备对象是不能独立存在的, 设备对象能够接收I/O请求, 但是却没有处理I/O请求的派遣函数, 处理I/O请求的派遣函数保存在驱动对象中.

因此, 在一个驱动项目中, 要有两种对象存在:

  1. 驱动对象 , 能够保存处理I/O请求的派遣函数.

  2. 设备对象 , 能够接收到I/O请求.

而且, 驱动对象无法被用户层代码所访问到 , 但是设备对象可以.

设备对象虽然在内核层, 但是创建了DOS下的符号链接之后, 在用户层中就可以通过CreateFile来打开设备对象. 并能够通过ReadFile,WriteFile,DeviceIOControl,GetFileSize等函数来间接地调用保存在驱动对象中的派遣函数. 它们的关系如下图所属:

1566986967836

设备对象的创建和销毁

  • IoCreateDevice - 创建设备对象

  • IoDeleteDevice - 销毁设备对象

符号链接

符号链接就是一个名字, \DosDevices\D:\\这是一个盘符, 但其实也可以视为一个符号链接名.它的作用是能够让用处层的API发出IO请求, 并能够在发出IO请求时指定一个设备来处理此IO请求:

当用户层的应用程序发出一个IO请求时, 对象管理器通过此符号链接名称来找到对应的设备, 对象管理器能够解析符号链接的名称, 以确定IO请求的目的地.

符号链接是给设备对象使用的, 设备对象默认没有符号链接, 没有符号链接的设备对象无法被用户层的代码所使用. 为设备对象创建符号链接之后才可以.

在内核中, 符号链接的种类有两种:

  • NT 设备名 - 设备名一般格式为"\Device\自定义设备名" , 此格式的名字一般是用于传递函数IoCreateDevice所要求给出的设备名. 这个设备名可以在内核下使用,但是用户层程序无法使用

  • DOS设备名 - 设备名一般格式为:"\DosDevices\自定义设备名" , 此格式的名字一般用户传递给函数IoCreateSymbolLinkName 的参数, 后面这个函数的功能是为一个NT设备名创建一个用户层能够使用的符号链接名.

    符号链接的创建和销毁

  • IoCreateSymbolicLink - 为一个NT设备名链接到一个DOS设备名 , DOS设备名可供用户层程序使用.

  • IoDeleteSymbolicLink - 删除一个DOS设备名.

在用户层中打开设备

当驱动对象创建了设备对象, 并且设备对象也建立了DOS符号链接:

// 驱动程序入口函数
NTSTATUS DriverEntry(DRIVER_OBJECT* driver, UNICODE_STRING* path) {
    driver->DriverUnload = NULL;

    UNICODE_STRING ntDeviceName;
    RtlInitUnicodeString(&ntDeviceName, L"\\Device\\dev_test_1");
    DEVICE_OBJECT* device;

    NTSTATUS ret;

    // 创建设备对象
    ret = IoCreateDevice(driver, /*用于创建设备对象的驱动对象*/
                         0, /*扩展数据大小*/
                         &ntDeviceName,  /*设备对象的NT设备名*/
                         FILE_DEVICE_UNKNOWN,  /*设备对象的类型*/
                         0,  /**/
                         0, /**/
                         &device/*被创建出来的设备对象*/);
    if (!NT_SUCCESS(ret)) {
        return ret;
   }

    UNICODE_STRING dosDeviceName;
    RtlInitUnicodeString(&dosDeviceName, L"\\DosDevice\\dev_test_1");
    // 为NT设备名创建一个DOS符号链接名.
    IoCreateSymbolicLink(&dosDeviceName, &ntDeviceName);
}

上面的代码创建的DOS设备名为:dev_test_1,(\\DosDevice\\只是一个前缀). 那么在三环中, 可以通过以下方式打开此DOS设备:

file = CreateFileW(L"\\\\.\\dev_test_1", /*设备名*/
                   GENERIC READ | GENERIC WRITE, /*设备的打开后的操作权限*/
                   0,
                   NULL,
                   OPEN_EXISTING,
                   0,
                   NULL);

派遣函数和IRP

派遣函数实则就是当设备接收到了IO请求之后被调用来处理IO请求的函数.

例如:

file = CreateFileW(L"\\\\.\\dev_test_1", /*设备名*/
                   GENERIC READ | GENERIC WRITE, /*设备的打开后的操作权限*/
                   0,
                   NULL,
                   OPEN_EXISTING,
                   0,
                   NULL);

上述代码打开了一个设备对象, 然后保存在驱动对象中派遣函数数组MajorFunction的第IRP_MJ_CREATE项就会被调用, 如果这个元素被设置为NULL,则不会被调用, 但是, CreateFile也将调用失败. 调用此函数后, 系统会将CreateFile传递的参数传递给派遣函数. 但派遣函数的原型(参数列表)中却没有这些形参:

typedef
NTSTATUS DRIVER_DISPATCH (
   _In_ struct _DEVICE_OBJECT *DeviceObject, /*设备对象*/
   _Inout_ struct _IRP *Irp /*IRP*/
   );

上面的代码就是所有派遣函数的原型, 只有一个设备对象, 和IRP结构体指针两个参数. 那么在用户层中传递过来的参数在哪里传递?

系统其实已经将这些参数保存在了IRPIO_STACK_LOCALTION结构中.

因此, IRP实际就是一个用户保存用户层传递进来的参数. 这些参数有多种 , 而且, 对于不同的IO请求, 会有不同的参数, 而无论什么IO请求 ,多少个参数, 都只能通过此结构体来保存, 因此这个结构体比较庞大.

在MSDN的文档中, 专门有介绍不同的IO请求下, 参数保存在IRP结构体中哪个字段:

1566987279551

1566987305231

IO_STACK_LOCATION

任何内核模式程序在创建一个IRP时,同时还创建了一个与之关联的IO_STACK_LOCATION结构数组:数组中的每个堆栈单元都对应一个将处理该IRP的驱动程序。 IRP的头部有一个当前IO_STACK_LOCATION的数组索引,同时也有一个指向该IO_STACK_LOCATION的指针。索引是从1开始,没有0。当驱动程序准备向次低层驱动程序传递IRP时可以调用IoCallDriver例程,它其中的一个工作是递减当前IO_STACK_LOCATION的索引,使之与下一层的驱动程序匹配。但该索引不会设置成0,如果设置成0,系统将会崩溃。就是说,最底层的驱动程序不会调用IoCallDriver例程。

这个数组一般是紧随IRP结构体之后. 通过IoGetCurrentIrpStackLocation 函数就能过获取到当前设备的IO栈.

IRP处理过程

IRP处理概览(例子)

下图是在用户层中打开文件时的过程(例如打开D:\\1.txt)

1566987350673

  1. 在用户层通过子系统调用I/O系统服务来打开命名文件

  2. 进入到内核层. 由IO管理器调用对象管理器去查找和解析文件对象的符号链接, 它会调用安全引用监视器来检查子系统是否具有正确的访问权限来打开文件对象.

  3. 如果卷尚未挂载,I/O管理器将暂时挂起打开的请求,并调用一个或多个文件系统,直到其中一个文件系统将文件对象识别为文件系统使用的大容量存储设备之一上存储的对象。当文件系统已安装卷时,I/O管理器恢复请求。

  4. I/O管理器为打开请求分配内存并初始化IRP, 同时也会分配IO_STACK_LOCATION数组。对于驱动程序,打开等同于“创建”请求。

  5. I/O管理器调用文件系统驱动程序,将IRP传递给它。文件系统驱动程序访问其在IRP中的I/O堆栈位置,以确定它必须执行什么操作,检查参数,确定所请求的文件是否在高速缓存中,如果没有,则在IRP中设置下一级驱动程序的I/O堆栈位置。

  6. 两个驱动程序都处理IRP并完成所请求的I/O操作,调用由I/O管理器和其他系统组件提供的内核模式支持例程(在前面的图中未示出)。

  7. 驱动程序将IRP返回到I/O管理器,并在IRP中设置I/O状态块,以指示所请求的操作是否成功或为什么失败。

  8. I/O管理器从IRP获取I/O状态,因此它可以通过受保护的子系统向原始调用者返回状态信息。

  9. 释放已经处理完成的IRP

  10. 如果打开操作成功,I/O管理器将文件对象句柄返回到子系统。如果存在错误,则返回适当的状态给子系统。

在子系统成功打开表示数据文件、设备或卷的文件对象之后,子系统使用返回的句柄在设备I/O操作的后续请求(通常是读、写或设备I/O控制请求)中标识文件对象。为了做出这样的请求,子系统调用I/O系统服务。I/O管理器将这些请求路由到发送给适当驱动程序的IRP。

IRP请求处理的详细过程(例子)

下图是在用户层中打开文件时的过程(例如打开D:\\1.txt)

1566987394266

  1. I/O管理器调用文件系统驱动程序(FSD), 并传递已分配给子系统的读/写请求的IRP。FSD访问在IRP中的I/O栈,以确定它应该执行什么操作。

  2. FSD通过调用I/O函数(IoAllocateIrp)一次或多次来分配额外的IRP,可以将原始请求分解为更小的请求(可能针对多个设备驱动程序)。附加的IRP返回到FSD(就是IRP栈),并为下级驱动器用零填充的I/O堆栈位置。FSD可以自行决定,通过在原始IRP中设置下一级驱动程序的I/O堆栈位置,并将其传递给较低级驱动程序,从而重用原始IRP,而不是像上图所示那样分配额外的IRP。

  3. 对于每个驱动程序分配的IRP,上一图中的FSD调用I/O支持例程来注册FSD提供的完成例程;在完成例程中,FSD可以确定较低驱动程序是否满足请求,并在较低驱动程序具有comp时释放每个驱动程序分配的IRP。让它过去吧。I/O管理器将调用FSD提供的完成例程,无论每个驱动程序分配的IRP是否成功完成、错误状态完成或取消。更高级别的驱动程序负责释放其分配的任何IRP,并为其自身设置低级别驱动程序。I/O管理器释放在所有驱动程序完成之后分配的IRP。

    接下来,FSD调用I/O支持例程(IoGetNextIrpStackLocation)来访问下一级驱动程序的I/O堆栈位置,以便设置对下一级驱动程序的请求。(在前面的图中,下一个较低级别的驱动程序碰巧是最低级别的驱动程序。)FSD然后调用I/O支持例程(IoCallDriver)将该IRP传递给下一个较低级别的驱动程序。

  4. 当用IRP调用它时,最低级别的驱动程序检查它的I/O堆栈位置,以确定应该在目标设备上执行什么操作(由IRP_MJ_XXX函数代码指示)。目标设备由设备对象在其指定的I/O堆栈位置中表示,并与IRP一起传递给驱动程序。最低级别的驱动程序可以假设I/O管理器已经将IRP路由到为IRP_MJ_XXX操作(这里是IRP_MJ_READIRP_MJ_WRITE)定义的驱动程序的入口点,并且较高级别的驱动程序已经检查了请求的其他参数的有效性。

    如果没有更高级别的驱动程序,那么最低级别的驱动程序将检查IRP_MJ_XXX操作的输入参数是否有效。如果是,则驱动程序通常调用I/O支持例程来告诉I/O管理器设备操作正在IRP上挂起,或者对IRP进行排队,或者将其传递到访问目标设备(这里是物理或逻辑设备:磁盘或分区)的另一个驱动程序提供的例程。在磁盘上)。

  5. I/O管理器确定驱动程序是否已经忙于处理目标设备的另一个IRP,如果是,则对IRP排队并返回。否则,I/O管理器将IRP路由到在其设备上启动I/O操作的驱动程序提供的例程。(在此阶段,前一个图中的两个驱动程序和I/O管理器都返回控制)。

  6. 当设备中断时,驱动程序的中断服务例程(ISR)所做的工作与它必须停止设备中断和保存有关操作的必要上下文所做的工作一样多。然后,ISR调用具有IRP的I/O支持例程(IoRequestDpc)来对驱动程序提供的DPC(延迟过程调用)例程进行排队,以比ISR更低的硬件优先级完成所请求的操作。

  7. 当驱动程序的DPC得到控制时,它使用上下文(在ISR调用IoRequestDpc中传递的)来完成I/O操作。DPC调用一个支持例程来取消下一个IRP(如果有的话)的队列,并将该IRP传递给在设备上启动I/O操作的驱动程序提供的例程(参见步骤5)。然后,DPC在IRP的I/O状态块中设置关于刚刚完成的操作的状态,并将其返回到具有IoCompleteRequest的I/O管理器。

  8. I/O管理器将IRP中最低层驱动程序的I/O堆栈位置置零,并用FSD分配的IRP调用文件系统的注册完成例程(参见步骤3)。此完成例程检查I/O状态块,以确定是重试请求还是更新关于原始请求维护的任何内部状态,并释放其驱动程序分配的IRP。文件系统可以收集它发送给低级驱动程序的所有驱动程序分配IRP的状态信息,以便它可以设置I/O状态并完成原始IRP。当文件系统已经完成原始IRP时,I/O管理器将向I/O操作的原始请求者(子系统的本机函数)返回和NTSTATUS值。

IRP处理流程中的IO栈(IO_STACK_COMPLATE)

I/O管理器为每个驱动程序提供一组分层驱动程序,为每个IRP建立一个I/O堆栈位置。每个I/O堆栈位置由 IO_STACK_LOCATION结构组成。

I/O管理器为每个IRP创建I/O栈数组,其中数组元素对应于分层驱动程序链中的每个驱动程序。每个驱动程序都拥有包中的一个栈位置,并调用IoGetCurrentIrpStackLocation以获得关于I/O操作的驱动程序特定信息。

这样的链中的每个驱动程序负责调用IoGetNextIrpStackLocation,然后设置下一个驱动程序的I/O堆栈位置。任何高级驱动程序的I/O堆栈位置也可以用于存储关于操作的上下文,以便驱动程序的IoCompletion例程可以执行其清理操作。

分层驱动程序中的处理IRP图显示了原始IRP中的两个I/O堆栈位置,因为它显示了两个驱动程序,一个文件系统驱动程序和一个大容量存储设备驱动程序。分层驱动程序中的处理IRP中的驱动程序分配IRP图没有创建它们的FSD的堆栈位置。为低级驱动程序分配IRP的任何高级驱动程序还根据下一级驱动程序的设备对象的StackSize值确定新IRP应该具有多少I/O堆栈位置。

下图更详细地显示了IRP的内容。

1566987412372

IRP处理流程总结

  1. IRP被IO管理器所创建

  2. 一个IRP被发出来,可以被多个驱动对象所处理, 每个驱动对象都能做出不同的处理.

    IO管理器创建IRP时, 会找出所有能够处理此IRP的设备对象, 并为每一个设备对象建立一个IRP栈元素,(一个驱动对象所创建的设备可以挂载到另一个驱动的设备对象链中,IoAttatchDeviceToDeviceStack)

    于是便有了IRP栈,

    通过函数IoGetCurrentIrpStackLocation能够获取到本驱动的IRP栈,

  3. 一个IRP被处理完成之后, 使用IoCompleteRequest来设置IRP的完成状态, 设置时,主要设置以下内容

    1. Irp.IoStatus.Status - 将完成的状态设置到此字段(成功了失败了, 总之得有一个状态码) , 这个状态码可以使用STATUS_XXXX这系列的宏.

    2. Irp.IoStatus.Information - 设置完成的字节数(如读取了多少字节, 写入了多少字节等等)

IRQL

IRQL即 :中断请求级别(Interrupt ReQuest Level,IRQL) .

内核实际就是一个进程(ntoskrnl.exe) , 里面有非常多的全局变量, 而且有大量的线程在运行. 当几个线程同时操作一个全局变量时, 就会出现问题, 为了解决这个问题, 微软这才提出了IRQL的概念. 这套东西主要是为了保证代码执行的优先级,原子级的, 它分为以下级别

  • Dispatch:所有运行在Dispatch级的代码都是会被进行原子操作的,且不能访问分页内存,也就是说操作系统中在一个时间内只能运行一段Dispatch级的代码,且必须将其完全执行完毕后才会发生线程切换

  • APC :比Dispatch低的一个级别,可以访问分页内存

  • Passive : 的优先级,大多数代码所运行的级别

MDL

typedef struct _MDL {
   struct _MDL *Next; //用于挂入到一个队列中,如插入到驱动程序的IRP的MDL队列中。
   CSHORT Size;//指定这个MDL所占的空间大小=MDL结构体的大小+sizeof(PFN_NUMBER)*映射需要的页面数。
   CSHORT MdlFlags; //指明MDL的映射方式
   struct _EPROCESS *Process; //指明此MDL属于哪个进程。
   PVOID MappedSystemVa; //所描述的内存如果有映射到系统空间并锁定。那么这个成员指定了MDL在系统空间内的地址
   PVOID StartVa; //所描述的内存映射后的虚拟地址的开始页面地址,这个地址总是页面对齐的地址
   ULONG ByteCount; //此MDL所描述的内存块有多少个字节
   ULONG ByteOffset; //MDL映射的虚拟地址的首地址在StartVa页面中的偏移值。
} MDL, *PMDL;

一个连续的虚拟内存地址范围可能是由多个分布(spread over)在不相邻的物理页所组成的。系统使用MDL(内存描述符表)结构体来表明虚拟内存缓冲区的物理页面布局。我们应该避免直接访问MDL。我们可以使用MS-Windows提供的宏,他们提供了对这个结构体基本 的访问。

  • MmGetMdlVirtualAddress 获取缓冲区的虚拟内存地址 ·

  • MmGetMdlByteCount 获取缓冲区的大小(字节数)

  • MmGetMdlByteOffset 获取缓冲区开端的物理页的大小(字节数)

  • MmGetMdlPfnArray 获取记录物理页码的一个数组指针。

我们可以用IoAllocateMdl函数来分配一个MDL。如果要取消分配,可是使用IoFreeMdl函数。或者,可以使用MmInitializeMdl来把一个之前定义的缓冲区定制成一个MDL。但是以上两种方式都不能初始化物理页码数组。

对于在非分页池中分配的缓冲区,可以用MmBuidlMdlForNonpagedPool函数来初始化页码数组。对于可分页的内存,虚拟内存和物理内存之间的联系是暂时的,所以MDL的页码数组只在特定的环境和时间段有效,因为很可能其他的程序对它们进行重新分配,为了使其他的程序无法对他们进行修改和重新分配(在我们释放之前),我们就需要把这段内存锁定,防止其他程序修改,我们可以用MmProbeAndLockPages来实现,这个函数同时还为当前的布局初始化了页码数组。当我们用MmUnlockPages来释放被锁定的内存时,页码数组也会随之无效。

假如MDL指定的是映射一块内核级别的虚拟地址空间,那么我们要用MmGetSystemAddressForMdlSafe ,这样我们就能防止映射目标是来自用户模式的空间,而来自用户模式空间的物理页只能在用户模式上下文环境中使用,并且随时可能被清空。用函数进行申明后,就可以防止以上情况发生了。

总结:MDL就是描述一块虚拟内存的结构体,里面有个成员记录了多个页码,这些页码即处于各个不同物理地址的物理块的页号。

所以要对一块受系统保护的区域进行写操作的话,可以这样来修改它的保护属性: 1.创建一个MDL,显然里面的物理页号数组没有初始化 IoAllocateMdl 2.初始化页码数组,使之成为实际有效的MDL MmBuildMdlForNonPagedPool 3.进行锁定,并且重新赋值新的保护属性为可读 MmProbeAndLockPages 4.获得我们所映射后的实际内存区域的虚拟地址 MmMapLockedPagesSpecifyCache

使用示例

   const wchar_t* pStr = L"123456789abcdefg0"; // 常量字符串,不可修改.
   // 创建一个内存描述符列表
   PMDL mdl = IoAllocateMdl(pStr, 17/*字节数*/, 0, 0, 0);
   // 为内存描述符列表建立虚拟内存分页
   MmBuildMdlForNonPagedPool(mdl);

   // 将虚拟内存加载到物理内存, 修改内存描述符分页属性为可写,并返回虚拟内存分页地址
   wchar_t* p = (wchar_t*)MmMapLockedPagesSpecifyCache(mdl, KernelMode, MmWriteCombined, 0, 0, 0);
   p[1] = 'A'; // 本来不可修改的内存现在可以修改了.

   // 取消锁定和映射
   MmUnmapLockedPages(p, mdl);
   // 释放内存描述符列表
   IoFreeMdl(mdl);

猜你喜欢

转载自www.cnblogs.com/ltyandy/p/11425709.html