1. 介绍

2017年3月,长亭安全研究实验室(Chaitin Security Research Lab)参加了 Pwn2Own 黑客大赛,我作为团队的一员,一直专注于 VMware Workstation Pro 的破解,并成功在赛前完成了一个虚拟机逃逸的漏洞利用。(很不)幸运的是,就在 Pwn2Own 比赛的前一天(3月14日),VMware 发布了一个新的版本,其中修复了我们所利用的漏洞。在本文中,我会介绍我们从发现漏洞到完成利用的整个过程。感谢@kelwin 在实现漏洞利用过程中给予的帮助,也感谢 ZDI 的朋友,他们近期也发布了一篇相关博客,正是这篇博文促使我们完成本篇 writeup。

本文主要由三部分组成:首先我们会简要介绍 VMware 中的 RPCI 机制,其次我们会描述本文使用的漏洞,最后讲解我们是如何利用这一个漏洞来绕过 ASLR 并实现代码执行的。

2. VMware RPCI 机制

VMware 实现了多种虚拟机(下文称为guest)与宿主机(下文称文host)之间的通信方式。其中一种方式是通过一个叫做 Backdoor 的接口,这种方式的设计很有趣,guest 只需在用户态就可以通过该接口发送命令。VMware Tools 也部分使用了这种接口来和 host 通信。我们来看部分相关代码(摘自 open-vm-tools 中的 lib/backdoor/backdoorGcc64.c ):

void
Backdoor_InOut(Backdoor_proto *myBp) // IN/OUT
{
uint64 dummy; __asm__ __volatile__(
#ifdef __APPLE__
/*
* Save %rbx on the stack because the Mac OS GCC doesn't want us to
* clobber it - it erroneously thinks %rbx is the PIC register.
* (Radar bug 7304232)
*/
"pushq %%rbx" "\n\t"
#endif
"pushq %%rax" "\n\t"
"movq 40(%%rax), %%rdi" "\n\t"
"movq 32(%%rax), %%rsi" "\n\t"
"movq 24(%%rax), %%rdx" "\n\t"
"movq 16(%%rax), %%rcx" "\n\t"
"movq 8(%%rax), %%rbx" "\n\t"
"movq (%%rax), %%rax" "\n\t"
"inl %%dx, %%eax" "\n\t" /* NB: There is no inq instruction */
"xchgq %%rax, (%%rsp)" "\n\t"
"movq %%rdi, 40(%%rax)" "\n\t"
"movq %%rsi, 32(%%rax)" "\n\t"
"movq %%rdx, 24(%%rax)" "\n\t"
"movq %%rcx, 16(%%rax)" "\n\t"
"movq %%rbx, 8(%%rax)" "\n\t"
"popq (%%rax)" "\n\t"
#ifdef __APPLE__
"popq %%rbx" "\n\t"
#endif
: "=a" (dummy)
: "0" (myBp)
/*
* vmware can modify the whole VM state without the compiler knowing
* it. So far it does not modify EFLAGS. --hpreg
*/
:
#ifndef __APPLE__
/* %rbx is unchanged at the end of the function on Mac OS. */
"rbx",
#endif
"rcx", "rdx", "rsi", "rdi", "memory"
);
}

上面的代码中出现了一个很奇怪的指令 inl。在通常环境下(例如 Linux 下默认的 I/O 权限设置),用户态程序是无法执行I/O指令的,因为这条指令只会让用户态程序出错并产生崩溃。而此处这条指令产生的权限错误会被 host 上的 hypervisor 捕捉,从而实现通信。Backdoor 所引入的这种从 guest 上的用户态程序直接和host通信的能力,带来了一个有趣的攻击面,这个攻击面正好满足 Pwn2Own 的要求:“在这个类型(指虚拟机逃逸这一类挑战)中,攻击必须从guest的非管理员帐号发起,并实现在 host 操作系统中执行任意代码”。guest 将 0x564D5868 存入 $eax,I/O 端口号 0x5658 或 0x5659 存储在 $dx 中,分别对应低带宽和高带宽通信。其它寄存器被用于传递参数,例如$ecx的低16位被用来存储命令号。对于 RPCI 通信,命令号会被设为 BDOOR_CMD_MESSAGE(=30)。文件 lib/include/backdoor_def.h 中包含了一些支持的 backdoor 命令列表。host 捕捉到错误后,会读取命令号并分发至相应的处理函数。此处我省略了很多细节,如果你有兴趣可以阅读相关源码。

2.1 RPCI

远程过程调用接口 RPCI(Remote Procedure Call Interface)是基于前面提到的Backdoor机制实现的。依赖这个机制,guest 能够向 host 发送请求来完成某些操作,例如,拖放(Drag n Drop)/复制粘贴(Copy Paste)操作、发送或获取信息等等。RPCI 请求的格式非常简单:<命令> <参数>。例如 RPCI 请求info-get guestinfo.ip 可以用来获取guest的IP地址。对于每个 RPCI 命令,在 vmware-vmx 进程中都有相关注册和处理操作。

需要注意的是有些RPCI命令是基于VMCI套接字实现的,但此内容已超出本文讨论的范畴。

3. 漏洞

花了一些时间逆向各种不同的 RPCI 处理函数之后,我决定专注于分析拖放(Drag n Drop,下面简称为 DnD )和复制粘贴(Copy Paste,下面简称为 CP)功能。这部分可能是最复杂的RPCI命令,也是最可能找到漏洞的地方。在深入理解的 DnD/CP 内部工作机理后,可以很容易发现,在没有用户交互的情况下,这些处理函数中的许多功能是无法调用的。DnD/CP 的核心功能维护了一个状态机,在无用户交互(例如拖动鼠标从 host 到 guest 中)情况下,许多状态是无法达到的。

我决定看一看 Pwnfest 2016 上被利用的漏洞,该漏洞在这个 VMware 安全公告中有所提及。此时我的 idb 已经标上了很多符号,所以很容易就通过 bindiff 找到了补丁的位置。下面的代码是修补之前存在漏洞的函数(可以看出 services/plugins/dndcp/dnddndCPMsgV4.c 中有对应源码,漏洞依然存在于 open-vm-tools 的 git 仓库的 master 分支当中):

static Bool
DnDCPMsgV4IsPacketValid(const uint8 *packet,
size_t packetSize)
{
DnDCPMsgHdrV4 *msgHdr = NULL;
ASSERT(packet); if (packetSize < DND_CP_MSG_HEADERSIZE_V4) {
return FALSE;
} msgHdr = (DnDCPMsgHdrV4 *)packet; /* Payload size is not valid. */
if (msgHdr->payloadSize > DND_CP_PACKET_MAX_PAYLOAD_SIZE_V4) {
return FALSE;
} /* Binary size is not valid. */
if (msgHdr->binarySize > DND_CP_MSG_MAX_BINARY_SIZE_V4) {
return FALSE;
} /* Payload size is more than binary size. */
if (msgHdr->payloadOffset + msgHdr->payloadSize > msgHdr->binarySize) { // [1]
return FALSE;
} return TRUE;
} Bool
DnDCPMsgV4_UnserializeMultiple(DnDCPMsgV4 *msg,
const uint8 *packet,
size_t packetSize)
{
DnDCPMsgHdrV4 *msgHdr = NULL;
ASSERT(msg);
ASSERT(packet); if (!DnDCPMsgV4IsPacketValid(packet, packetSize)) {
return FALSE;
} msgHdr = (DnDCPMsgHdrV4 *)packet; /*
* For each session, there is at most 1 big message. If the received
* sessionId is different with buffered one, the received packet is for
* another another new message. Destroy old buffered message.
*/
if (msg->binary &&
msg->hdr.sessionId != msgHdr->sessionId) {
DnDCPMsgV4_Destroy(msg);
} /* Offset should be 0 for new message. */
if (NULL == msg->binary && msgHdr->payloadOffset != 0) {
return FALSE;
} /* For existing buffered message, the payload offset should match. */
if (msg->binary &&
msg->hdr.sessionId == msgHdr->sessionId &&
msg->hdr.payloadOffset != msgHdr->payloadOffset) {
return FALSE;
} if (NULL == msg->binary) {
memcpy(msg, msgHdr, DND_CP_MSG_HEADERSIZE_V4);
msg->binary = Util_SafeMalloc(msg->hdr.binarySize);
} /* msg->hdr.payloadOffset is used as received binary size. */
memcpy(msg->binary + msg->hdr.payloadOffset,
packet + DND_CP_MSG_HEADERSIZE_V4,
msgHdr->payloadSize); // [2]
msg->hdr.payloadOffset += msgHdr->payloadSize;
return TRUE;
}

对于 Version 4 的 DnD/CP 功能,当 guest 发送分片 DnD/CP 命令数据包时,host 会调用上面的函数来重组 guest 发送的 DnD/CP 消息。接收的第一个包必须满足 payloadOffset 为 0,binarySize 代表堆上分配的 buffer 长度。[1]处的检查比较了包头中的 binarySize,用来确保 payloadOffset 和 payloadSize 不会越界。在[2]处,数据会被拷入分配的 buffer 中。但是[1]处的检查存在问题,它只对接收的第一个包有效,对于后续的数据包,这个检查是无效的,因为代码预期包头中的 binarySize 和分片流中的第一个包相同,但实际上你可以在后续的包中指定更大的 binarySize 来满足检查,并触发堆溢出。

所以,该漏洞可以通过发送下面的两个分片来触发:

packet 1{
...
binarySize = 0x100
payloadOffset = 0
payloadSize = 0x50
sessionId = 0x41414141
...
#...0x50 bytes...#
} packet 2{
...
binarySize = 0x1000
payloadOffset = 0x50
payloadSize = 0x100
sessionId = 0x41414141
...
#...0x100 bytes...#
}

有了以上的知识,我决定看看 Version 3 中的 DnD/CP 功能中是不是也存在类似的问题。令人惊讶的是,几乎相同的漏洞存在于 Version 3 的代码中(这个漏洞最初通过逆向分析来发现,但是我们后来意识到 v3 的代码也在 open-vm-tools 的 git 仓库中):

Bool
DnD_TransportBufAppendPacket(DnDTransportBuffer *buf, // IN/OUT
DnDTransportPacketHeader *packet, // IN
size_t packetSize) // IN
{
ASSERT(buf);
ASSERT(packetSize == (packet->payloadSize + DND_TRANSPORT_PACKET_HEADER_SIZE) &&
packetSize <= DND_MAX_TRANSPORT_PACKET_SIZE &&
(packet->payloadSize + packet->offset) <= packet->totalSize &&
packet->totalSize <= DNDMSG_MAX_ARGSZ); if (packetSize != (packet->payloadSize + DND_TRANSPORT_PACKET_HEADER_SIZE) ||
packetSize > DND_MAX_TRANSPORT_PACKET_SIZE ||
(packet->payloadSize + packet->offset) > packet->totalSize || //[1]
packet->totalSize > DNDMSG_MAX_ARGSZ) {
goto error;
} /*
* If seqNum does not match, it means either this is the first packet, or there
* is a timeout in another side. Reset the buffer in all cases.
*/
if (buf->seqNum != packet->seqNum) {
DnD_TransportBufReset(buf);
} if (!buf->buffer) {
ASSERT(!packet->offset);
if (packet->offset) {
goto error;
}
buf->buffer = Util_SafeMalloc(packet->totalSize);
buf->totalSize = packet->totalSize;
buf->seqNum = packet->seqNum;
buf->offset = 0;
} if (buf->offset != packet->offset) {
goto error;
} memcpy(buf->buffer + buf->offset,
packet->payload,
packet->payloadSize);
buf->offset += packet->payloadSize;
return TRUE; error:
DnD_TransportBufReset(buf);
return FALSE;
}

Version 3 的 DnD/CP 在分片重组时,上面的函数会被调用。此处我们可以在[1]处看到与之前相同的情形,代码依然假设后续分片中的 totalSize 会和第一个分片一致。因此这个漏洞可以用和之前相同的方法触发:

packet 1{
...
totalSize = 0x100
payloadOffset = 0
payloadSize = 0x50
seqNum = 0x41414141
...
#...0x50 bytes...#
} packet 2{
...
totalSize = 0x1000
payloadOffset = 0x50
payloadSize = 0x100
seqNum = 0x41414141
...
#...0x100 bytes...#
}

在 Pwn2Own 这样的比赛中,这个漏洞是很弱的,因为它只是受到之前漏洞的启发,而且甚至可以说是同一个。因此,这样的漏洞在赛前被修补并不惊讶(好吧,也许我们并不希望这个漏洞在比赛前一天被修复)。对应的 VMware 安全公告在这里。受到这个漏洞影响的 VMWare Workstation Pro 最新版本是12.5.3。

接下来,让我们看一看这个漏洞是如何被用来完成从 guest 到 host 的逃逸的!

4. 漏洞利用

为了实现代码执行,我们需要在堆上覆盖一个函数指针,或者破坏C++对象的虚表指针。

首先让我们看一看如何将 DnD/CP 协议的设置为 version 3,依次发送下列 RPCI 命令即可:

tools.capability.dnd_version 3
tools.capability.copypaste_version 3
vmx.capability.dnd_version
vmx.capability.copypaste_version

前两行消息分别设置了 DnD 和 Copy/Paste 的版本,后续两行用来查询版本,这是必须的,因为只有查询版本才会真正触发版本切换。RPCI 命令 vmx.capability.dnd_version 会检查 DnD/CP 协议的版本是否已被修改,如果是,就会创建一个对应版本的C++对象。对于 version 3,2个大小为 0xA8 的C++对象会被创建,一个用于 DnD 命令,另一个用于 Copy/Paste 命令。

这个漏洞不仅可以让我们控制分配的大小和溢出的大小,而且能够让我们进行多次越界写。理想的话,我们可以用它分配大小为0xA8的内存块,并让它分配在C++对象之前,然后利用堆溢出改写C++对象的 vtable 指针,使其指向可控内存,从而实现代码执行。

这并非易事,在此之前我们必须解决一些其他问题。首先我们需要找到一个方法来绕过 ASLR,同时处理好Windows Low Fragmented Heap。

4.1 绕过ASLR

一般来说,我们需要找到一个对象,通过溢出来影响它,然后实现信息泄露。例如破坏一个带有长度或者数据指针的对象,并且可以从guest读取,然而我们没有找到这种对象。于是我们逆向了更多的RPCI命令处理函数,来寻找可用的东西。那些成对的命令特别引人关注,例如你能用一个命令来设置一些数据,同时又能用相关命令来取回数据,最终我们找到的是一对命令info-set和info-get:

info-set guestinfo.KEY VALUE
info-get guestinfo.KEY

VALUE 是一个字符串,字符串的长度可以控制堆上 buffer 的分配长度,而且我们可以分配任意多的字符串。但是如何用这些字符串来泄露数据呢?我们可以通过溢出来覆盖结尾的null字节,让字符串连接上相邻的内存块。如果我们能够在发生溢出的内存块和 DnD 或 CP 对象之间分配一个字符串,那么我们就能泄露对象的 
vtable 地址,从而我们就可以知道 vmware-vmx 的地址。尽管 Windows 的 LFH 堆分配存在随机化,但我们能够分配任意多的字符串,因此可以增加实现上述堆布局的可能性,但是我们仍然无法控制溢出buffer后面分配的是 DnD 还是 CP 对象。经过我们的测试,通过调整一些参数,例如分配和释放不同数量的字符串,我们可以实现60%到80%的成功率。

下图总结了我们构建的堆布局情况(Ov代表溢出内存块,S代表String,T代表目标对象)。

我们的策略是:首先分配一些填满“A”的字符串,然后通过溢出写入一些“B”,接下来读取所有分配的字符串,其中含有“B”的就是被溢出的字符串。这样我们就找到了一个字符串可以被用来读取泄露的数据,然后以 bucket 的内存块大小 0xA8 的粒度继续溢出,每次溢出后都检查泄露的数据。由于 DnD 和 CP 对象的 vtable 距离 vmware-vmx 基地址的偏移是固定的,每次溢出后只需要检查最低一些数据位,就能够判断溢出是否到达了目标对象。

4.2 获取代码执行

现在我们实现了信息泄露,也能知道溢出的是哪个C++对象,接下来要实现代码执行。我们需要处理两种情形:溢出 CopyPaste 和 DnD 。需要指出的是能利用的代码路径有很多,我们只是选择了其中一个。

4.2.1 覆盖 CopyPaste 对象

对于 CopyPaste 对象,我们可以覆盖虚表指针,让它指向我们可控的其他数据。我们需要找到一个指针,指针指向的数据是可控并被用做对象的虚表。为此我们使用了另一个 RPCI 命令 unity.window.contents.start。这个命令主要用于 Unity 模式下,在 host 上绘制一些图像。这个操作可以让我们往相对 vmware-vmx 偏移已知的位置写入一些数据。该命令接收的参数是图像的宽度和高度,二者都是32位,合并起来我们就在已知位置获得了一个64位的数据。我们用它来作为虚表中的一个指针,通过发送一个 CopyPast 命令即可触发该虚函数调用,步骤如下:

  • 发送 unity.window.contents.start 命令,通过指定参数宽度和高度,往全局变量处写入一个64位的栈迁移 gadget 地址
  • 覆盖对象虚表指针,指向伪造的虚表(调整虚表地址偏移)
  • 发送 CopyPaste 命令,触发虚函数调用
  • ROP
4.2.2 覆盖DnD对象

对于 DnD 对象,我们不能只覆盖 vtable 指针,因为在发生溢出之后 vtable 会立马被访问,另一个虚函数会被调用,而目前我们只能通过 unity 图像的宽度和高度控制一个 qword,所以无法控制更大的虚表。

让我们看一看 DnD 和 CP 对象的结构,总结如下(一些类似的结构可以在 open-vm-tools 中找到,但是在 vmware-vmx 中会略有区别):

DnD_CopyPaste_RpcV3{
void * vtable;
...
uint64_t ifacetype;
RpcUtil{
void * vtable;
RpcBase * mRpc;
DnDTransportBuffer{
uint64_t seqNum;
uint8_t * buffer;
uint64_t totalSize;
uint64_t offset;
...
}
...
}
} RpcBase{
void * vtable;
...
}

我们在此省略了结构中很多与本文无关的属性。对象中有个指针指向另一个C++对象 RpcBase,如果我们能用一个可控数据的指针的指针覆盖 mRpc 这个域,那我们就控制了 RpcBase 的 vtable。对此我们可以继续使用 unity.window.contents.start 命令来来控制 mRpc,该命令的另一个参数是 imgsize,这个参数代表分配的图像 buffer 的大小。这个 buffer 分配出来后,它的地址会存在 vmware-vmx 的固定偏移处。我们可以使用命令 unity.window.contents.chunk 来填充 buffer 的内容。步骤如下:

  • 发送unity.window.contents.start命令来分配一个buffer,后续我们用它来存储一个伪造的vtable。
  • 发送unity.window.contents.chunk命令来填充伪造的vtable,其中填入一个栈迁移的gadget
  • 通过溢出覆盖DnD对象的mRpc域,让它指向存储buffer地址的地方(某全局变量处),即写入一个指针的指针
  • 通过发送DnD命令来触发mRpc域的虚函数调用
  • ROP

P.S:vmware-vmx 进程中有一个可读可写可执行的内存页(至少在版本12.5.3中存在)。

4.3 稳定性讨论

正如前面提及的,因为 Windows LFH 堆的随机化,当前的 exploit 无法做到 100% 成功率。不过可以尝试下列方法来提高成功率:

  • 观察 0xA8 大小的内存分配,考虑是否可以通过一些malloc和free的调用来实现确定性的LFH分配,参考这里这里
  • 寻找堆上的其他C++对象,尤其是那些可以在堆上喷射的
  • 寻找堆上其他带有函数指针的对象,尤其是那些可以在堆上喷射的
  • 找到一个独立的信息泄漏漏洞
  • 打开更多脑洞
4.4 演示效果

演示视频:VMware workstation 12.5.3逃逸演示

【转载】利用一个堆溢出漏洞实现 VMware 虚拟机逃逸的更多相关文章

  1. vmware漏洞之一——转:利用一个堆溢出漏洞实现VMware虚拟机逃逸

    转:https://zhuanlan.zhihu.com/p/27733895?utm_source=tuicool&utm_medium=referral 小结: vmware通过Backd ...

  2. vmware漏洞之三——Vmware虚拟机逃逸漏洞(CVE-2017-4901)Exploit代码分析与利用

    本文简单分析了代码的结构.有助于理解. 转:http://www.freebuf.com/news/141442.html 0×01 事件分析 2017年7月19 unamer在其github上发布了 ...

  3. Linux堆溢出漏洞利用之unlink

    Linux堆溢出漏洞利用之unlink 作者:走位@阿里聚安全 0 前言 之前我们深入了解了glibc malloc的运行机制(文章链接请看文末▼),下面就让我们开始真正的堆溢出漏洞利用学习吧.说实话 ...

  4. 实战Java虚拟机之中的一个“堆溢出处理”

    从今天開始.我会发5个关于java虚拟机的小系列: 实战Java虚拟机之中的一个"堆溢出处理" 实战Java虚拟机之二"虚拟机的工作模式" 实战Java虚拟机之 ...

  5. 转-CVE-2016-10190浅析-FFmpeg堆溢出漏洞

    本文转载自CVE-2016-10190 FFmpeg Heap Overflow 漏洞分析及利用 前言 FFmpeg是一个著名的处理音视频的开源项目,使用者众多.2016年末paulcher发现FFm ...

  6. CVE-2010-2553:Microsoft Cinepak Codec CVDecompress 函数堆溢出漏洞调试分析

    0x01 前言 微软提供一个叫 Cinepak 的视频解码器,通过调用 iccvid.dll 这个动态链接库文件可以使用这个解码器:微软自带的 Windows Media Player(视频音频软件) ...

  7. GitHub现VMware虚拟机逃逸EXP,利用三月曝光的CVE-2017-4901漏洞

    今年的Pwn2Own大赛后,VMware近期针对其ESXi.Wordstation和Fusion部分产品发布更新,修复在黑客大赛中揭露的一些高危漏洞.事实上在大赛开始之前VMware就紧急修复了一个编 ...

  8. vmware漏洞之二——简评:实战VMware虚拟机逃逸漏洞

    下文取自360,是vmware exploit作者自己撰写的.本文从实验角度对作者的文章进行解释,有助于学习和理解.文章虚线内或红色括号内为本人撰写. ------------------------ ...

  9. VMware 虚拟机逃逸漏洞

    所谓虚拟机逃逸(Escape Exploit),指的是突破虚拟机的限制,实现与宿主机操作系统交互的一个过程,攻击者可以通过虚拟机逃逸感染宿主机或者在宿主机上运行恶意软件. 针对 VMware 的虚拟机 ...

随机推荐

  1. LCD时序中设计到的VSPW/VBPD/VFPD/HSPW/HBPD/HFPD总结【转】

    转自:https://blog.csdn.net/u011603302/article/details/50732406 下面是我在网上摘录的一些关于LCD信号所需时钟的一些介绍, 描述方式一: 来自 ...

  2. Ubuntu下安装arm-linux-gnueabi-xxx编译器【转】

    转自:http://blog.csdn.net/real_myth/article/details/51481639 from: http://www.linuxdiyf.com/linux/1948 ...

  3. fc26 url

    aarch64 http://linux.yz.yamagata-u.ac.jp/pub/linux/fedora-projects/fedora-secondary/releases/26/Ever ...

  4. javascript本地缓存方案-- 存储对象和设置过期时间

    cz-storage 解决问题 1. 前端js使用localStorage的时候只能存字符串,不能存储对象 cz-storage 可以存储 object undefined number string ...

  5. No.17 selenium学习之路之判断与等待

    一.三种等待方式 1.sleep 加载time库.time.sleep() 休眠单位以秒为单位 2.implicitly_wait() 等待页面完全加载完成(左上角转圈结束) 参数为等待时间,等待页面 ...

  6. 洛谷P2024食物链

    传送门啦 这道题的特殊之处在于对于任意一个并查集,只要告诉你某个节点的物种,你就可以知道所有节点对应的物种. 比如一条长为4的链 甲->乙->丙->丁 ,我们知道乙是A物种.那么甲一 ...

  7. java多线程-读写锁原理

    Java5 在 java.util.concurrent 包中已经包含了读写锁.尽管如此,我们还是应该了解其实现背后的原理. 读/写锁的 Java 实现(Read / Write Lock Java ...

  8. 利用Octopress在github pages上搭建个人博客

    利用Octopress在github pages上搭建个人博客 SEP 29TH, 2013 在GitHub Pages上用Octopress搭建博客,需要安装ruby环境.git环境等.本人在Fed ...

  9. CF 586B 起点到终点的最短路和次短路之和

    起点是右下角  终点是左上角 每次数据都是两行的点  输入n 表示有n列 接下来来的2行是 列与列之间的距离 最后一行是  行之间的距离 枚举就行   Sample test(s) input 41 ...

  10. Pytest里,mark装饰器的使用,双引号,没引号,这种差别很重要

    按最新版的pytest测试框架. 如果只是单一的mark,不要加任何引号. 如果是要作and ,not之类的先把,一定要是双引号! 这个要记清楚,好像和以前版本的书上介绍的不一样,切记! import ...