0%

Linux IPC 学习

Linux IPC 学习

起因

专门去学习Linux IPC的缘由是CNSS 2020 Recruit的一道招新题,题目直接给了一个shell,但是获取了shell连上服务器之后,由于flag权限不足,只能通过readflag程序来读取flag,而readflag这个程序需要进行简单的程序交互,通过题目给的shell是无法完成这一操作的,我们需要编写一个交互程序,来自动完成与readflag的交互让其输出flag。

这个题的预期解是通过运行一个perl程序来完成交互,服务器上perl环境已经安装好了。但是这个预期解的局限性也就在于必须得有perl环境才能正常操作,深入思考了预期解的payload,会发现其实整个程序的本质就是两个linux进程之间的通信过程,进程间通信我们也可以通过c或者go等语言,编译成binary直接丢上服务器运行,这样就可以不依赖任何特定的语言环境了。

进程/线程

首先简单讲讲进程和线程相关的概念。

进程与线程的概念

进程:操作系统进行资源分配的基本单位。

线程:操作系统进行调度和执行的基本单位。

进程的描述

操作系统中主要使用进程控制块(即PCB)来描述进程。Linux中的PCB是task_struct类型结构体。

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#include <linux/sched.h>
// reference: https://www.cs.fsu.edu/~baker/opsys/examples/task_struct.html
struct task_struct {
/*
* offsets of these are hardcoded elsewhere - touch with care
*/
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
unsigned long flags; /* per process flags, defined below */
int sigpending;
mm_segment_t addr_limit; /* thread address space:
0-0xBFFFFFFF for user-thead
0-0xFFFFFFFF for kernel-thread
*/
struct exec_domain *exec_domain;
volatile long need_resched;
unsigned long ptrace;

int lock_depth; /* Lock depth */

/*
* offset 32 begins here on 32-bit platforms. We keep
* all fields in a single cacheline that are needed for
* the goodness() loop in schedule().
*/
long counter;
long nice;
unsigned long policy;
struct mm_struct *mm;
int processor;
/*
* cpus_runnable is ~0 if the process is not running on any
* CPU. It's (1 << cpu) if it's running on a CPU. This mask
* is updated under the runqueue lock.
*
* To determine whether a process might run on a CPU, this
* mask is AND-ed with cpus_allowed.
*/
unsigned long cpus_runnable, cpus_allowed;
/*
* (only the 'next' pointer fits into the cacheline, but
* that's just fine.)
*/
struct list_head run_list;
unsigned long sleep_time;

struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_head local_pages;
unsigned int allocation_order, nr_local_pages;

/* task state */
struct linux_binfmt *binfmt;
int exit_code, exit_signal;
int pdeath_signal; /* The signal sent when the parent dies */
/* ??? */
unsigned long personality;
int did_exec:1;
pid_t pid;
pid_t pgrp;
pid_t tty_old_pgrp;
pid_t session;
pid_t tgid;
/* boolean value for session group leader */
int leader;
/*
* pointers to (original) parent process, youngest child, younger sibling,
* older sibling, respectively. (p->father can be replaced with
* p->p_pptr->pid)
*/
struct task_struct *p_opptr, *p_pptr, *p_cptr, *p_ysptr, *p_osptr;
struct list_head thread_group;

/* PID hash table linkage. */
struct task_struct *pidhash_next;
struct task_struct **pidhash_pprev;

wait_queue_head_t wait_chldexit; /* for wait4() */
struct completion *vfork_done; /* for vfork() */
unsigned long rt_priority;
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_incr;
struct timer_list real_timer;
struct tms times;
unsigned long start_time;
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];
/* mm fault and swap info: this can arguably be seen as either mm-specific or thread-specific */
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable:1;
/* process credentials */
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
int ngroups;
gid_t groups[NGROUPS];
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;
int keep_capabilities:1;
struct user_struct *user;
/* limits */
struct rlimit rlim[RLIM_NLIMITS];
unsigned short used_math;
char comm[16];
/* file system info */
int link_count, total_link_count;
struct tty_struct *tty; /* NULL if no tty */
unsigned int locks; /* How many file locks are being held */
/* ipc stuff */
struct sem_undo *semundo;
struct sem_queue *semsleeping;
/* CPU-specific state of this task */
struct thread_struct thread;
/* filesystem information */
struct fs_struct *fs;
/* open file information */
struct files_struct *files;
/* signal handlers */
spinlock_t sigmask_lock; /* Protects signal and blocked */
struct signal_struct *sig;

sigset_t blocked;
struct sigpending pending;

unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;

/* Thread group tracking */
u32 parent_exec_id;
u32 self_exec_id;
/* Protection of (de-)allocation: mm, files, fs, tty */
spinlock_t alloc_lock;

/* journalling filesystem info */
void *journal_info;
};

进程状态

R, TASK_RUNNING:就绪态或者运行态,进程就绪可以运行,但是不一定正在占有CPU
S, TASK_INTERRUPTIBLE:浅度睡眠,等待资源,可以响应信号,一般是进程主动sleep进入的状态
D, TASK_UNINTERRUPTIBLE:深度睡眠,等待资源,不响应信号,典型场景是进程获取信号量阻塞
Z, TASK_ZOMBIE:僵尸态,进程已退出或者结束,但是父进程还不知道,没有回收时的状态
T, TASK_STOPED:停止,调试状态,收到SIGSTOP信号进程挂起

进程的创建与消亡

(1) system()
通过调用shell启动一个新进程

(2) exec()
以替换当前进程映像的方式启动一个新进程

(3) fork()
以复制当前进程映像的方式启动一个新进程,子进程中fork()返回0,父进程fork()返回为子进程ID。

(4) wait()
父进程挂起,等待子进程结束。

(5) 孤儿进程与僵尸进程
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。孤儿进程不会浪费资源。
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵尸进程。僵尸进程会浪费系统资源。
僵尸进程产生原因:

  1. 子进程结束后向父进程发出SIGCHLD信号,父进程默认忽略了它。

  2. 父进程没有调用wait()或waitpid()函数来等待子进程的结束。

避免僵尸进程的方法:

  1. 父进程调用wait()或者waitpid()等待子进程结束,这样处理父进程一般会阻塞在wait处而不能处理其他事情。
  2. 捕捉SIGCHLD信号,并在信号处理函数里面调用wait函数,这样处理可避免1中描述的问题。
  3. fork两次,父进程创建儿子进程,儿子进程再创建一个孙子进程,然后儿子进程自杀,孙子进程成为孤儿进程被init进程收养。

进程相关基础操作(c语言)

fork()函数

fork()函数用于创建一个进程。

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
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void print_process_message() {
pid_t myprocess_id = getpid();
uid_t uid = getuid();
gid_t ugid = getgid();
printf("getpid = %d getuid= %d getgid= %d \n", myprocess_id, uid, ugid);
}

int main(int argc, char const *argv[]) {
int n = 0;
printf("before fork: n = %d\n",n);

pid_t fpid = fork();

if( fpid < 0 ) { // fork error
perror("fork error");
exit(EXIT_FAILURE);
} else if (fpid == 0) { // child process
n++;
printf("child_proc(%d, ppid=%d): n= %d\n", getpid(), getppid(), n);
} else { // parent process
n--;
printf("parent_proc(%d): n= %d\n",getpid(),n);
}
print_process_message();
printf("quit_proc(%d) ...\n",getpid());
return 0;
}

注意fork()函数执行一次返回两次,一次是父进程返回,返回大于0的数(子进程ID),一次是子进程返回,若执行成功返回0。

fork() 和 vfork()

fork创建子进程,把父进程数据空间、堆和栈复制一份;
vfork创建子进程,与父进程内存数据共享;
但是后来的fork也进行了改变,不是一开始调用fork就复制数据,而是只有在子进程要修改数据的时候,才进行复制,即copy-on-write(COW写时拷贝技术)

wait和waitpid函数

pid_t wait(int \*status)等待任意子进程退出,并捕获退出状态
pid_t waitpid(pid_t pid, int \*status, int options)等待子进程退出,并捕获退出状态

两个函数返回的都是退出的子进程的id。

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <wait.h>

int main(int argc, char const *argv[],char *envp[]) {
pid_t fpid = fork(), pid;
if ( fpid < 0 ) {
perror("fork error");
exit(EXIT_FAILURE);
} else if ( fpid == 0 ) {
sleep(5);
exit(5);
} else {
int stat;
while (1) {
pid = waitpid(fpid, &stat, WNOHANG); //stat用于记录子进程的返回结果
if ( pid > 0) {
break;
} else {
printf("wait child proc ... \n");
sleep(1);
}
}
if(WIFEXITED(stat)) { //这个函数如果子进程正常退出的话返回真
printf("child_proc(%d): exit_code :%d\n", pid, WEXITSTATUS(stat));
}
}
return 0;
}

程序启动了一个子进程,休眠5秒后结束,父进程每1秒轮询一次子进程的状态,若子进程已退出,pid为子进程ID大于0,则退出循环,否则继续等待。

可以使用两种方式来处理子进程的退出,一种是通过signal()函数处理SIGCHLD信号,例如忽略该信号以防止出现僵尸进程,另一种方式就是通过wait()和waitpid()函数来回收子进程以防止出现僵尸进程。

exec系列函数

exec()是一个系统调用,它将以新的进程空间替换现在的进程空间执行程序,进程pid不变。调用exec后,系统会申请一块新的进程空间来存放被调用的程序,然后当前进程会携带pid跳转到新的进程空间,并从main函数开始执行,旧的进程空间被回收。

1
2
3
4
5
6
7
8
9
#include <stdio.h>
#include <unistd.h>

int main(int argc ,const char **argv)
{
char *argv_ch[]={"ls","-al","/usr/include/linux",NULL};
char *envp_ch[]={0,NULL};
execve("/bin/ls",argv_ch,envp_ch);
}

程序通过execve()函数执行/bin/ls程序,并传入参数和环境变量。程序将用新的子程序进程空间替换当前程序的进程空间来执行程序,进程pid不变。

守护进程

Linux Daemon进程是运行在后台的一种特殊进程。
一个守护进程的父进程是init进程,因为它真正的父进程在fork出子进程后就先于子进程exit退出了,所以它是一个由init继承的孤儿进程;
守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理;
守护进程的名称通常以d结尾,比如sshd,xinetd,crond等

函数原型:

1
2
#include <unistd.h>
int daemon(int nochdir, int noclose);

system()和popen()函数

system函数执行一个shell命令,system()函数调用/bin/sh来执行参数指定的命令,/bin/sh一般是一个软连接,指向某个具体的shell,比如bash。

system()函数的调用执行了三步操作:

  1. fork一个子进程
  2. 在子进程中调用exec函数去执行command
  3. 在父进程中调用wait去等待子进程结束;

注意:system()并不能获取命令执行的输出结果,只能得到执行的返回值;

popen函数启动另外一个进程去执行一个shell命令行,称调用popen的进程为父进程,由popen启动的进程称为子进程。

popen函数还创建一个管道用于父子进程间通信,父进程要么从管道读信息,要么向管道写信息,至于是读还是写取决于父进程调用popen时传递的参数。

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[]){
if(argc < 2){
fprintf(stderr, "usage: %s <cmd>\n", argv[0]);
exit(EXIT_FAILURE);
}

char output[1024+1];

FILE *pp = popen(argv[1], "r"); // 指定创建一个只读管道,用于从中读子进程命令执行的输出
if(pp == NULL){
perror("popen error");
exit(EXIT_FAILURE);
}

int nread = fread(output, 1, 1024, pp); //父进程通过文件指针读取子进程的输出设备。
int status = pclose(pp);
if(status < 0){
perror("pclose error");
exit(EXIT_FAILURE);
}

output[nread] = '\0';
if(WIFEXITED(status)){
printf("status: %d\n%s", WEXITSTATUS(status), output);
}
return 0;
}

程序通过popen()函数创建子进程执行指定的命令,并创建一个只读管道,用于从中读子进程命令执行的输出。

signal信号

信号(signal)是一种软中断,信号机制是进程间通信的一种方式,采用异步通信方式。

几个主要的信号:

信号 含义
SIGINT(2) 中断(即CTRL + C)
SIGKILL(9) kill信号(强杀,进程不能阻止)
SIGPIPE(13) 管道破损,没有读端的管道写数据,就是那个brokenpipe。默认杀进程,所以网络编程中要处理这个信号。(当服务器close一个连接时,根据TCP协议的规定,会收到一个RST响应,client再往这个服务器发送数据时,系统会发出一个SIGPIPE信号给进程,告诉进程这个连接已经断开了,不要再写了。)
SIGTERM(15) 终止信号,这个不是强制的,它可以被捕获和解释(或忽略)的过程。类似于和这个进程商量一下,让它退出。
SIGCHLD(17) 子进程退出,默认忽略
SIGSTOP(19) 进程停止,不能被忽略、处理和阻塞
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>

void handle_signal(int signum){
printf("received signal: %d\n", signum);
exit(0);
}

int main(void){
signal(SIGINT, handle_signal);

for(;;){
printf("running ... \n");
sleep(1);
}
return 0;
}

程序启动后持续监听,直到有任何信号发送给程序,输出信号的值后退出。

进程间通信

Linux的所有进程间通信方式

  1. 管道:在创建时分配一个page大小的内存,缓存区大小比较有限;
  2. 消息队列:信息复制两次,额外的CPU消耗;不合适频繁或信息量大的通信;
  3. 共享内存:无须复制,共享缓冲区直接付附加到进程虚拟地址空间,速度快;但进程间的同步问题操作系统无法实现,必须各进程利用同步工具解决;
  4. 套接字:作为更通用的接口,传输效率低,主要用于不通机器或跨网络的通信;
  5. 信号量:常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  6. 信号: 不适用于信息交换,更适用于进程中断控制,比如非法内存访问,杀死某个进程等;

这里主要详细介绍管道通信和unix域套接字通信。

管道

管道是一个进程连接数据流到另一个进程的通道,它通常是用作把一个进程的输出通过管道连接到另一个进程的输入。例如我们在shell中输入命令:ls -l | grep string,我们知道ls命令会把当前目录中的文件都列出来,但是它不会直接输出,而是把本来要输出到屏幕上的数据通过管道输出到grep这个进程中,作为grep这个进程的输入,然后这个进程对输入的信息进行筛选,把存在string的信息的字符串(以行为单位)打印在屏幕上。

popen(), pclose()函数
1
2
3
#include <stdio.h>
FILE* popen(const char *command, const char *open_mode);
int pclose(FILE *stream_to_close);

popen()函数允许一个程序将另一个程序作为新进程来启动,并可以传递数据给它或者通过它接收数据。command是要运行的程序名和相应的参数。open_mode只能是”r”和”w”的其中之一。注意,popen()函数的返回值是一个FILE类型的指针,也就是说我们可以使用stdio I/O库中的文件处理函数来对其进行操作。

如果open_mode是”r”,主调用程序就可以使用被调用程序的输出,通过函数返回的FILE指针使用stdio函数(如fread)来读取程序的输出;如果open_mode是”w”,主调用程序就可以向被调用程序发送数据,即通过stdio函数(如fwrite)向被调用程序写数据,而被调用程序就可以在自己的标准输入中读取这些数据。

pclose()函数用于关闭由popen创建出的关联文件流。pclose()只在popen启动的进程结束后才返回,如果调用pclose()时被调用进程仍在运行,pclose()调用将等待该进程结束。它返回关闭的文件流所在进程的退出码。

管道默认是阻塞模式的,通过fcntl(fd, F_SETFL, flags | O_NONBLOCK)可以设置非阻塞的管道

我们将ls -l | grep a命令使用程序实现一下:

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
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main() {
FILE *read_fp = NULL;
FILE *write_fp = NULL;
char buffer[BUFSIZ + 1];
int chars_read = 0;

memset(buffer, '\0', sizeof(buffer));

// 打开ls和grep进程
read_fp = popen("ls -l", "r");
write_fp = popen("grep a", "w");

if (read_fp && write_fp) {
// 读取一个数据块
chars_read = fread(buffer, sizeof(char), BUFSIZ, read_fp);
while (chars_read > 0) {
buffer[chars_read] = '\0';

// 把数据写入grep进程
fwrite(buffer, sizeof(char), chars_read, write_fp);

// 还有数据可读,循环读取数据,直到读完所有数据
chars_read = fread(buffer, sizeof(char), BUFSIZ, read_fp);
}

pclose(read_fp);
pclose(write_fp);

exit(EXIT_SUCCESS);
}

exit(EXIT_FAILURE);
}
popen()的实现方式和优缺点:

当请求popen()调用运行一个程序时,它首先启动shell,即系统中的sh命令,然后将command字符串作为一个参数传递给它。

这样就带来了一个优点和一个缺点。优点是:在Linux中所有的参数扩展都是由shell来完成的。所以在启动程序(command中的命令程序)之前先启动shell来分析命令字符串,也就可以使各种shell扩展(如通配符)在程序启动之前就全部完成,这样我们就可以通过popen()启动非常复杂的shell命令。

而它的缺点就是:对于每个popen()调用,不仅要启动一个被请求的程序,还要启动一个shell,即每一个popen()调用将启动两个进程,从效率和资源的角度看,popen()函数的调用比正常方式要慢一些。

另外popen()函数还有一个缺点,每次打开的一个进程只能创建一个读或者写的管道,也就是说我们不能实现与子进程的完全全双工交互,实现这个功能我们就需要使用pipe()函数。

pipe()函数

pipe()函数是一个底层的调用,与popen()函数不同的是,它在两个进程之间传递数据不需要启动一个shell来解释请求命令,同时它还提供对读写数据的更多的控制。

pipe()函数的原型如下:

1
2
#include <unistd.h>
int pipe(int file_descriptor[2]);

可以看到pipe()函数的定义非常特别,该函数在数组中包含两个新的文件描述符后返回0,如果返回返回-1,并设置errno()来说明失败原因。数组中的两个文件描述符以一种特殊的方式连接起来,数据基于先进先出的原则,写到file_descriptor[1]的所有数据都可以从file_descriptor[0]读回来。由于数据基于先进先出的原则,所以读取的数据和写入的数据是一致的。

我们可以使用pipe()函数实现匿名管道通信。

匿名管道

一个简单的匿名管道实现父子进程通信的实例。

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
45
46
47
48
49
50
51
52
53
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>

int main(int argc, char *argv[]){
if(argc < 3) {
fprintf(stderr, "usage: %s parent_sendmsg child_sendmsg\n", argv[0]);
exit(EXIT_FAILURE);
}

int pipes[2];
if(pipe(pipes) < 0) {
perror("pipe");
exit(EXIT_FAILURE);
}

pid_t pid = fork();
if ( pid < 0 ) {
perror("fork");
exit(EXIT_FAILURE);
} else if ( pid > 0) { // child
char buf[BUFSIZ + 1];
int nbuf;
strcpy(buf, argv[1]);
write(pipes[1], buf, strlen(buf));

// 这里sleep是为了让子进程有时间把管道中的数据读走,不然数据就会被底下的父进程的read读走
// 因为实质上内核中只有一个管道缓冲区,是父进程创建的,只不过子进程同时拥有了它的引用
sleep(1);

nbuf = read(pipes[0], buf, BUFSIZ);
buf[nbuf] = 0;
printf("parent_proc(%d) recv_from_child: %s\n", getpid(), buf);

close(pipes[0]);
close(pipes[1]);
} else if ( pid == 0 ) { // parent
char buf[BUFSIZ + 1];
int nbuf = read(pipes[0], buf, BUFSIZ);
buf[nbuf] = 0;
printf("child_proc(%d) recv_from_parent: %s\n", getpid(), buf);

strcpy(buf, argv[2]);
write(pipes[1], buf, strlen(buf));

close(pipes[0]);
close(pipes[1]);
}

return 0;
}

实际中为了实现双向通信,应该准备两根管道,一根负责从父进程往子进程写数据(同时子进程从这里读取数据),一根负责从子进程往父进程写数据(父进程也从这里读数据)。例如:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>

#define READ 0
#define WRITE 1

int doubleInteract(const char* cmdstring)
{
const char *sendStr = "y\n";
int pipeR[2]; // 父读子写
int pipeW[2]; // 父写子读
pid_t pid;
char buf[1024];
int len = sizeof(buf);
memset(buf, 0, len);

/*初始化管道*/
pipe(pipeR);
pipe(pipeW);
if ((pid = fork()) < 0) return -1;
if (pid > 0) { /* parent process */
close(pipeW[READ]);
close(pipeR[WRITE]);

read(pipeR[READ], buf, len);
printf("child:%s", buf); // child: 'do you want flag?(y/n)'

write(pipeW[WRITE], sendStr, strlen(sendStr)+1);
printf("parent:%s", sendStr);

memset(buf,0,len);
read(pipeR[READ], buf, len); // here is the flag! flag{xxxxxx}
printf("child:%s", buf);

close(pipeW[WRITE]);
close(pipeR[READ]);
waitpid(pid, NULL, 0);
}
else {/* child process, 关闭写管道的写端,读管道的读端,与父进程正好相对 */
close(pipeW[WRITE]);
close(pipeR[READ]);
//重定向子进程的标准输入,标准输出到管道端
if (pipeW[READ] != STDOUT_FILENO)
{
if (dup2(pipeW[READ], STDIN_FILENO) != STDIN_FILENO)
{
return -1;
}
close(pipeW[READ]);
}

if (pipeR[WRITE] != STDOUT_FILENO)
{
if (dup2(pipeR[WRITE], STDOUT_FILENO) != STDOUT_FILENO)
{
return -1;
}
close(pipeR[WRITE]);
}
execl("/bin/sh", "sh", "-c", cmdstring, (char*)0);
exit(127);
}
return 0;
}

int main()
{
doubleInteract("/readflag");
return 0;
}

一个简化版的readflag,需要向程序中输入y才能读取到flag,这里可以采用匿名管道的方式,创建两个管道,一个管道读,一个管道写,实现父子进程的双向交互,从而成功获取flag。

命名管道

命名管道的定义

命名管道也被称为FIFO文件,它是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但是它的行为却和之前所讲的没有名字的管道(匿名管道)类似。

由于Linux中所有的事物都可被视为文件,所以对命名管道的使用也就变得与文件操作非常的统一,也使它的使用非常方便,同时我们也可以像平常的文件名一样在命令中使用。

创建命名管道

我们可以使用两下函数之一来创建一个命名管道,他们的原型如下:

1
2
3
4
5
6
#include <sys/types.h>
#include <sys/stat.h>
// recommand
int mkfifo(const char *filename, mode_t mode);
// deprecated
int mknod(const char *filename, mode_t mode | S_IFIFO, (dev_t)0);

这两个函数都能创建一个FIFO文件,注意是创建一个真实存在于文件系统中的文件,filename指定了文件名,而mode则指定了文件的读写权限。

注:mknod是比较老的函数,而使用mkfifo函数更加简单和规范,所以在可能的情况下,尽量使用mkfifo而不是mknod。

举个例子,通过命名管道完成文件数据的发送和收取。

write_data.c

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <limits.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdio.h>
#include <string.h>

int main() {
const char *fifo_name = "/tmp/tmp_fifo";
int pipe_fd = -1;
int data_fd = -1;
int res = 0;
const int open_mode = O_WRONLY;
int bytes_sent = 0;
char buffer[PIPE_BUF + 1];

if (access(fifo_name, F_OK) == -1) {
// 管道文件不存在
// 创建命名管道
res = mkfifo(fifo_name, 0777);
if (res != 0) {
fprintf(stderr, "Could not create fifo %s\n", fifo_name);
exit(EXIT_FAILURE);
}
}

printf("Process %d opening FIFO O_WRONLY\n", getpid());

// 以只写阻塞方式打开FIFO文件,以只读方式打开数据文件
pipe_fd = open(fifo_name, open_mode);
data_fd = open("/a.txt", O_RDONLY);
printf("Process %d result %d\n", getpid(), pipe_fd);

if (pipe_fd != -1) {
int bytes_read = 0;

// 向数据文件读取数据
bytes_read = read(data_fd, buffer, PIPE_BUF);
buffer[bytes_read] = '\0';
while (bytes_read > 0)
{
// 向FIFO文件写数据
res = write(pipe_fd, buffer, bytes_read);
if (res == -1) {
fprintf(stderr, "Write error on pipe\n");
exit(EXIT_FAILURE);
}

// 累加写的字节数,并继续读取数据
bytes_sent += res;
bytes_read = read(data_fd, buffer, PIPE_BUF);
buffer[bytes_read] = '\0';
}
close(pipe_fd);
close(data_fd);
}
else {
exit(EXIT_FAILURE);
}

printf("Process %d finished\n", getpid());
exit(EXIT_SUCCESS);
}

read_data.c

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
45
46
47
48
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>
#include <string.h>

int main() {
const char *fifo_name = "/tmp/my_fifo";
int pipe_fd = -1;
int data_fd = -1;
int res = 0;
int open_mode = O_RDONLY;
char buffer[PIPE_BUF + 1];
int bytes_read = 0;
int bytes_write = 0;

// 清空缓冲数组
memset(buffer, '\0', sizeof(buffer));

printf("Process %d opening FIFO O_RDONLY\n", getpid());

// 以只读阻塞方式打开管道文件,注意与fifowrite.c文件中的FIFO同名
pipe_fd = open(fifo_name, open_mode);

// 以只写方式创建保存数据的文件
data_fd = open("/recvfromfifo.txt", O_WRONLY | O_CREAT, 0644);
printf("Process %d result %d\n", getpid(), pipe_fd);

if (pipe_fd != -1) {
do {
// 读取FIFO中的数据,并把它保存在文件DataFormFIFO.txt文件中
res = read(pipe_fd, buffer, PIPE_BUF);
bytes_write = write(data_fd, buffer, res);
bytes_read += res;
} while (res > 0);
close(pipe_fd);
close(data_fd);
}
else {
exit(EXIT_FAILURE);
}

printf("Process %d finished, %d bytes read\n", getpid(), bytes_read);
exit(EXIT_SUCCESS);
}

命名管道相比于匿名管道的优点是可以实现两个完全不相干的进程之间的通信,只需要同时去访问一个管道即可,无需是父子进程关系。

Unix Domain Socket

socket原本是为了网络通讯设计的,但是后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。
虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:

  1. 不需要经过网络协议栈;
  2. 不需要打包拆包;
  3. 不需要计算校验和;
  4. 不需要维护序号和应答;

这是因为IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。

UNIX Domain Socket也提供面向流和面向数据报两种API接口,类似TCP和UDP,但是面向数据报的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。

使用UNIX Domain Socket的过程和网络socket十分相似,也要先调用socket()创建一个socket文件描述符,address family指定为AF_UNIX,type可以选择SOCK_STREAM或SOCK_DGRAM,protocol参数仍然指定为0即可。

UNIX Domain Socket与网络socket编程最明显的不同在于地址格式不同,用结构体sockaddr_un表示。

网络编程的socket地址是IP地址加端口号,而UNIX Domain Socket的地址是一个socket类型的文件在文件系统中的路径,这个socket文件由bind()调用创建,如果调用bind()时该文件已经存在,则bind()错误返回。

server.c

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <ctype.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <signal.h>
#include <sys/wait.h>

#define SOCK_PATH "/run/echo.sock"
#define BUF_SIZE 1024

int listenfd;
void handle_signal(int signo);

int main(void){
signal(SIGINT, handle_signal);
signal(SIGHUP, handle_signal);
signal(SIGTERM, handle_signal);

if((listenfd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0){
perror("socket");
exit(EXIT_FAILURE);
}

struct sockaddr_un servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sun_family = AF_UNIX;
strcpy(servaddr.sun_path, SOCK_PATH);

unlink(SOCK_PATH);
if(bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0){ //因为这里要在/var/目录下创建一个临时文件,这个程序需要sudo运行
perror("bind");
exit(EXIT_FAILURE);
}
chmod(SOCK_PATH, 00640);

if(listen(listenfd, SOMAXCONN) < 0){
perror("listen");
exit(EXIT_FAILURE);
}

int connfd, nbuf;
char buf[BUF_SIZE + 1];
for(;;){
if((connfd = accept(listenfd, NULL, NULL)) < 0){
perror("accept");
continue;
}

nbuf = recv(connfd, buf, BUF_SIZE, 0);
buf[nbuf] = 0;
printf("new msg: \"%s\"\n", buf);
send(connfd, buf, nbuf, 0);

close(connfd);
}

return 0;
}

void handle_signal(int signo){
if(signo == SIGINT){
fprintf(stderr, "received signal: SIGINT(%d)\n", signo);
}else if(signo == SIGHUP){
fprintf(stderr, "received signal: SIGHUP(%d)\n", signo);
}else if(signo == SIGTERM){
fprintf(stderr, "received signal: SIGTERM(%d)\n", signo);
}

close(listenfd);
unlink(SOCK_PATH);
exit(EXIT_SUCCESS);
}

client.c

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
45
46
47
48
49
50
51
52
53
54
55
56
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <ctype.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <netdb.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <signal.h>
#include <sys/wait.h>

#define SOCK_PATH "/run/echo.sock"
#define BUF_SIZE 1024

int main(int argc, char *argv[]){
if(argc < 2){
fprintf(stderr, "usage: %s msg\n", argv[0]);
exit(EXIT_FAILURE);
}

int sockfd;
if((sockfd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0){
perror("socket");
exit(EXIT_FAILURE);
}

struct sockaddr_un servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sun_family = AF_UNIX;
strcpy(servaddr.sun_path, SOCK_PATH);

if(connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0){
perror("connect");
exit(EXIT_FAILURE);
}

char buf[BUF_SIZE + 1];
int nbuf;

nbuf = strlen(argv[1]);
send(sockfd, argv[1], nbuf, 0);
nbuf = recv(sockfd, buf, BUF_SIZE, 0);
buf[nbuf] = 0;
printf("echo msg: \"%s\"\n", buf);

close(sockfd);
return 0;
}

上述程序实现了通过unix domain socket的client-server 数据传输,就像是通过/var/echo.sock这个文件传输数据。有点类似于命名管道,但是Unix Domain Socket的使用更加广泛。例如wsl2与windows主机的通信,nginx与django进程的通信都是采用unix domain socket来完成。

原题目采用IPC的思路完成最终payload

由于c语言实现两个进程的多次连续交互存在一些复杂性,这里采用封装更完善,效率更高且更加安全的go语言完成payload编写。

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
45
package main

import (
"fmt"
"os/exec"
"strconv"
)

func main() {
cmd_getflag := exec.Command("/readflag")
getflag_stdin_pipe, getflag_stdin_pipe_error := cmd_getflag.StdinPipe()
getflag_stdout_pipe, getflag_stdout_pipe_error := cmd_getflag.StdoutPipe()
if getflag_stdin_pipe_error != nil {
fmt.Println("Open getflag stdin pipe error:", getflag_stdin_pipe_error)
return
}
if getflag_stdout_pipe_error != nil {
fmt.Println("Open getflag stdout pipe error:", getflag_stdout_pipe_error)
return
}
if getflag_start_error := cmd_getflag.Start(); getflag_start_error != nil {
fmt.Println("Run getflag error:", getflag_start_error)
return
}
// get the output of getflag
buf := make([]byte, 300)
n, _ := getflag_stdout_pipe.Read(buf)
fmt.Println(string(buf[:n]))
getflag_stdin_pipe.Write([]byte("y\n"))
n, _ = getflag_stdout_pipe.Read(buf)
fmt.Println(string(buf[:n]))
getflag_stdin_pipe.Write([]byte("2\n"))
n, _ = getflag_stdout_pipe.Read(buf)
fmt.Println(string(buf[n-16 : n]))
buf1 := string(buf[n-16 : n-9])
buf2 := string(buf[n-8 : n-1])
a1, _ := strconv.Atoi(buf1)
a2, _ := strconv.Atoi(buf2)
res := strconv.Itoa(a1 + a2)
getflag_stdin_pipe.Write([]byte(res + "\n"))
n, _ = getflag_stdout_pipe.Read(buf)
fmt.Println(string(buf[:n]))
return
}

这样我们就可以不依赖于特定语言环境完成题目要求。