asisctf 2023 pwn nightjs 复现记录

发布时间 2023-10-17 22:23:13作者: giacomo捏

关于赛题

asisctf2023 nightjs 附件 一道 js 引擎题目,基于 serenityOS 上做的改编。(感觉 serenity 作者很 强迫症 善良得写了很多注释,连一行赋值语句都有注释,读起来超级愉悦)。

b84496fab7ea760b061328fc6169713e

偷看了别人的wp来复现。给了 patch 文件之后,查看修改处的函数被什么函数引用,漏洞点就很明了了。攻击流程不难,不记录了。不过第一次见到脚本语言引擎的题目,有一些磕磕绊绊,这里总结一下收获。

js 执行过程

serenity js (和一般的脚本语言类似的)执行过程大概分为两个阶段:

  • parce souce code to bytecode
  • run bytecode 。

主要的处理部分在 parse_and_run 中。把源码解析成一个个字节码之后 run 的过程,其实就是找到每个 bytecode 对应的函数执行。

JS::Bytecode::Instruction::execute () 涉及从 bytecode 找到对应 function 的逻辑(感觉写的好优雅哇)。以 bytecode CALL 来举例,其类型是 Instruction::Type::Call 所对应的函数是 JS::Bytecode::Op::Call::execute_impl()

ALWAYS_INLINE ThrowCompletionOr<void> Instruction::execute(Bytecode::Interpreter& interpreter) const
{
#define __BYTECODE_OP(op)       \
    case Instruction::Type::op: \
        return static_cast<Bytecode::Op::op const&>(*this).execute_impl(interpreter);

    switch (type()) {
        ENUMERATE_BYTECODE_OPS(__BYTECODE_OP)
    default:
        VERIFY_NOT_REACHED();
    }

#undef __BYTECODE_OP
}


这段函数中有两个宏,define __BYTECODE_OP(op) 在函数中定义了,而 ENUMERATE_BYTECODE_OPS 宏定义如下:

#define ENUMERATE_BYTECODE_OPS(O)      \
    O(Add)                             \
    O(Append)                          \
    O(BitwiseAnd)                      \
    ...太多了省略...\
    O(UnsignedRightShift)              \
    O(Yield)

使得这一条语句可以从

switch (type()) {
        ENUMERATE_BYTECODE_OPS(__BYTECODE_OP)
default:
        VERIFY_NOT_REACHED();

扩展(宏ENUMERATE_BYTECODE_OPS(O))成为

switch (type()) {
            __BYTECODE_OP(Add)                             
            __BYTECODE_OP(Append)                          
            __BYTECODE_OP(BitwiseAnd)                      
            ...太多了省略...
            __BYTECODE_OP(UnsignedRightShift)              
            __BYTECODE_OP(Yield)
default:
        VERIFY_NOT_REACHED();

在变换(宏 __BYTECODE_OP(op) )成为

switch (type()) {
	case Instruction::Type::Add: 
        return static_cast<Bytecode::Op::Add const&>(*this).execute_impl(interpreter);  
    case Instruction::Type::Append: 
        return static_cast<Bytecode::Op::Append const&>(*this).execute_impl(interpreter); 
    case Instruction::Type::BitwiseAnd: 
        return static_cast<Bytecode::Op::BitwiseAnd const&>(*this).execute_impl(interpreter);
            ...太多了省略...
    case Instruction::Type::UnsignedRightShift: 
        return static_cast<Bytecode::Op::UnsignedRightShift const&>(*this).execute_impl(interpreter);  
    case Instruction::Type::Yield: 
        return static_cast<Bytecode::Op::Yield const&>(*this).execute_impl(interpreter);
default:
        VERIFY_NOT_REACHED();

如何调试

cpp 的代码经过优化即使编译时指定保存符号表,符号信息也太不完整,比如这题留下来的是函数名、类名。一些变量、结构体、类的信息就找不到了,所以有时候得对着内存嗯看布局。

因为命令较多,所以从文件中加载 gdb 命令:

# gdb ./js -x de(此文件名)

aslr on
set env LD_LIBRARY_PATH ./libjs
set args ./1.js
set substitute-path /pwn/asispwns/js/build/serenity/Meta/Lagom/../../ /mnt/d/wslubuntu2204/night/serenity-799b465fac5672f167d6fec599fe167bce92862d/
set substitute-path /pwn/asispwns/js/build/serenity/ /mnt/d/wslubuntu2204/night/serenity-799b465fac5672f167d6fec599fe167bce92862d/
add-symbol-file ./libjs/liblagom-js.so
b copy_data_block_bytes

gdb 设置源码路径

我希望 gdb 能够从我的机器加载源码,用到了这些命令:

info sources 查看写在二进制中的源码文件路径(显示的是出题人的文件路径,不是我的电脑的文件)

set substitute-path [from] [to] 替换源码路径。对于 info sources 的结果是绝对路径直接替换(正则)

directory 如果是相对路径可以用此命令设置查找的路径

动态库函数符号

对于动态库里的函数,如果没有程序没有 run,还没读取动态库的符号,可能无法在程序没有启动时下动态库函数的断点。

add-symbol-file [file] 可以提前读文件的符号表下断点

看内存

比如说我用 new ArrayBuffer(0x110); 实例化了一个 arraybuffer 对象,除了看函数返回值,还有其他方法找到这个对象在内存中的地址吗?

search 命令搜索内存即可

image-20231017004525330

这样就找到了对象的内存。结合源码对照一下就知道布局是怎样得了

outline: 
    vtable          		0x100
    vtable	       		 	0
    0		       		 	0
    0		        		0
    m_outline_buffer        m_outline_capacity 
    0		        		0    
    m_size          		m_inline
    m_detach_key    		0x7ffei

函数调用信息

bt backtrace

info symbol addr 查看地址所在的函数

pwndbg> vmmap libc.so.6
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
    0x7efd501b1000     0x7efd501d9000 r--p    28000      0 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7efd501d9000     0x7efd5036e000 r-xp   195000  28000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7efd5036e000     0x7efd503c6000 r--p    58000 1bd000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7efd503c6000     0x7efd503ca000 r--p     4000 214000 /usr/lib/x86_64-linux-gnu/libc.so.6
    0x7efd503ca000     0x7efd503cc000 rw-p     2000 218000 /usr/lib/x86_64-linux-gnu/libc.so.6
pwndbg> info symbol (0x7efd501b1000 + 0xa5120)
malloc in section .text of /lib/x86_64-linux-gnu/libc.so.6

动态库加载

在本题中,从 liblagom-js.so 的地址可以计算 libc.so.6 的地址,两个动态库的偏移是固定的,于是想看看这些动态库加载的过程是怎样的。

Position Independent Code

Eli Bendersky 的文章 详细介绍了 PICx64 PIC 例子

Position Independent Code (位置无关代码)是一种现在流行的动态库的格式,至少在 x64 上是强制 PIC 的。简单来说,动态库加载进内存不是以一个整体,而是分成好几个块(section)加载的。

比如我们可以用 vmmap 看见一个 lib 被分成多个段,有 code、data 段等。

image-20231017104733958

这样安排的原因是,动态库不知道自己会被加载到内存的那个位置,因此在加载之前,和地址有关的代码部分是空余出来的,比如说数据和函数地址,在加载时候才会填上真正的地址。而且位置有关的部分(data)和位置无关的部分(code)属于不同的段,段与段的偏移是固定的,因此 code 段不用改变就可以从固定的偏移的地方读到 data 中的内容。如图:

image-20231017110446314

PIC 的好处是:

  • 节约 RAM(位置无关代码被加载到内存中一次,然后映射到许多进程的虚拟内存中)
  • 提升加载的效率(在加载时,只有位置相关部分需要被修改,不需要修改整个库的内容)
  • 不同的 section 可以有不同的读写权限(不然 code 段也能随便写,容易被 hack)

dl-map-segments

现在去看看 glibc 怎么给一个动态库分配空间的,相关的代码在 _dl_map_segments 函数。

首先一次性分配一整个动态库所需的全部空间

l->l_map_start = _dl_map_segment (c, mappref, maplength, fd);

image-20231017113800247

可以看见这 0x5000 的数据是没有分段的

随后根据存储 lib 的分段信息的 loadcmds 数组,分割这块已分配的虚拟内存

数组中的结构体定义如下

struct loadcmd
{
  ElfW(Addr) mapstart, mapend, dataend, allocend, mapalign;
  ElfW(Off) mapoff;
  int prot;                             /* PROT_* bits.  */
};

prot 的取值可以是

#define PROT_READ        0x1                /* Page can be read.  */
#define PROT_WRITE        0x2                /* Page can be written.  */
#define PROT_EXEC        0x4                /* Page can be executed.  */
#define PROT_NONE        0x0                /* Page can not be accessed.  */

在 gdb 中可以看见长这样

image-20231016142108466

下面这个 while 循环的作用就是遍历 loadcmds 数组给整个动态库区域分段

  while (c < &loadcmds[nloadcmds])
    {
      if (c->mapend > c->mapstart
          /* Map the segment contents from the file.  */
          && (__mmap ((void *) (l->l_addr + c->mapstart),
                      c->mapend - c->mapstart, c->prot,
                      MAP_FIXED|MAP_COPY|MAP_FILE,
                      fd, c->mapoff)

完成之后:image-20231017115527893

因为分配每个动态库的整体的空间时候(区别于 while 循环里面给动态库区域分段)使用的是

__mmap ((void *) mappref, maplength, c->prot,
				MAP_COPY|MAP_FILE, fd, c->mapoff);
//如果动态库没有 preference 想要分配到哪里的话 mappref = 0,os 自己决定动态库的地址

因此动态库的位置怎么加载其实和 mmap 的算法有关系。mmap 的源码...下次一定看吧。

environ

i386 静态 可执行文件_start 过程,没找到 x86_64 的,找了一个 i386 的来参考下。

_environ 指向环境变量列表(可用 export 命令设置的那些),因为在程序启动的过程中,环境变量列表存在栈上(甚至在 _start 之前就在了),因此可以拿到栈的位置。

image-20231016145717780

环境变量在程序启动的时候就会传入。比如说 execve 的参数 envp 就是环境变量表。

int execve(const char *pathname, char *const argv[], char *const envp[]);

x86_64 initial entry point code 在 \sysdeps\x86_64\dl-machine.h 中,此前装载器会把用户的参数和环境变量压入栈中,按照其压栈的方法,实际上栈顶的元素是argc,而接着其下就是argv和环境变量的数组。

_start:
	movq %rsp, %rdi
	call _dl_start
	...

_dl_sysdep_start 中,计算栈地址给 _environ 变量赋值。

DL_FIND_ARG_COMPONENTS (start_argptr, _dl_argc, _dl_argv, _environ,
			  GLRO(dl_auxv));

# define DL_FIND_ARG_COMPONENTS(cookie, argc, argv, envp, auxp)	\
  do {									      \
    void **_tmp;							      \
    (argc) = *(long int *) cookie;					      \
    (argv) = (char **) ((long int *) cookie + 1);			      \
    (envp) = (argv) + (argc) + 1;					      \
    for (_tmp = (void **) (envp); *_tmp; ++_tmp)			      \
      continue;								      \
    (auxp) = (void *) ++_tmp;						      \
  } while (0)