Build-A-Web-Server

我想在笔记中记录方法和思考。所以内容可能会变得迷惑~
SOURCE
WRITEUP
TOOLS-系统调用表

  • socket
  • bind
  • listen
  • accept

套接字系统调用-Socket

1
int socket(int domain, int type, int protocol)

socket()创建一个通信端点,返回指向这个端点的文件描述符。

  • domain:指定一个通信域;选择协议簇
  • type:指定通信语义
  • protocol:指定套接字使用的协议

为了建立Socket system call,首先需要找到相关参数的值(AF_INET)(SOCK_STREAM)

Great question! In C programming, these constants are defined in header files like <sys/socket.h>. However, when working in assembly, you need to use their numeric values directly. These values are standardized across systems:

AF_INET is usually defined as 2.
SOCK_STREAM is usually defined as 1.
You can find these definitions in the C header files on your system. For example, you can look at /usr/include/x86_64-linux-gnu/bits/socket.h or similar files depending on your system architecture.

If you’re curious, you can use the grep command to search for these definitions in your system’s include directories.

1
2
3
grep -r "#define AF_INET" /usr/include
grep -r "#define SOCK_STREAM" /usr/include
grep -r "IPPROTO_IP" /usr/include

更便捷的办法可以是用python的pwn.constants速查

1
2
3
import pwn
pwn.constants.AF_INET
pwn.constants.SOCK_STREAM
1
2
3
4
>>> pwn.constants.AF_INET
Constant('AF_INET', 0x2)
>>> pwn.constants.SOCK_STREAM
Constant('SOCK_STREAM', 0x1)

之后就是查找系统调用表。
注意:存入寄存器中的系统调用号必须是十六进制

系统调用-Bind

Q: 既然Socket是通信的端点,那为什么有了Socket还要Bind?
A: 套接字需要被分配到网络实体,这包括具体的IP地址和端口,还要接受收到的数据(这就意味着要与内存交互)
因此,Bind要求有3个参数:

1
2
3
4
int bind(int sockfd, 
const struct sockaddr *addr,
socklen_t addrlen);
# Binding is essential because it ensures your server listens on a known address, making it reachable by clients.
  • sockfd:指向套接字的文件描述符
  • *addr:指向分配到套接字的地址(需要一个结构体)
  • addrlen:指定由addr指向的地址结构的大小,以字节为单位。
1
2
3
4
struct sockaddr {
sa_family_t sa_family; /* Address family */
char sa_data[]; /* Socket address */
};

更具体地,
{sa_family=AF_INET,
sin_port=htons(),
sin_addr=inet_addr(““)}
写成汇编代码:

1
2
3
4
5
6
.section .data
sockaddr:
.2byte 2 # AF_INET
.2byte 0x5000 # Port 80
.4byte 0 # Address 0.0.0.0
.8byte 0 # Additional 8 bytes of padding

在这里可以看到,Port80,是按照大端存储的,这在网络通信中常见,而小端存储为内存中的操作带来了不少便捷

1
2
3
4
5
6
7
8
9
10
11
12
mov rdi,3
lea rsi, [rip+sockaddr]
mov rdx, 16
mov rax, 0x31
syscall

.section .data
sockaddr:
.2byte 2
.2byte 0x5000
.4byte 0
.8byte 0

我还记得在《CSAPP》中,老师说过想清楚lea指令是关键。
之前只是知道是这样,但这次看到了实际使用=>指向地址的指针变量赋值用lea
lea指令专门用于计算地址,而mov指令用于加载数据值,两者的用途不同。
例如,lea rsi, [rip+sockaddr]会计算rip+sockaddr的地址,并将这个地址加载到rsi寄存器中。

系统调用-Listen

1
int listen(int sockfd, int backlog);
  • sockfd:socket的文件描述符
  • backlog:指定sockfd的待处理连接队列长度的最大值

如何保存rax=3

rax中存储的套接字描述符会被Bind Syscall的返回值覆盖
=>使用栈去保存
push rax
pop rdi
之后将backlog设置为0,这里不需要队列
mov rdi,0
mov rax,0x32
syscall

系统调用-Accept

用accept系统调用,它会等待客户端的连接。当建立连接时,它会返回一个新的套接字文件描述符,专门用于与该客户端通信,并且会用客户端的详细信息填充一个提供的地址结构(例如struct sockaddr_in)。这一过程是将你的服务器从被动监听者转变为积极通信者的关键步骤。

1
int accept(int sockfd, struct sockaddr *_Nullable restrict addr, socklen_t *_Nullable restrict addrlen);
  • sockfd
  • addr:指向sockaddr结构体的指针
  • addrlen:addr指向的结构体的长度
1
2
3
4
5
6
7
8
9
10
11
12
===== Trace: Parent Process =====
[✓] execve("/proc/self/fd/3", ["/proc/self/fd/3"], 0x70277df35a60 /* 0 vars */) = 0
[✓] socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
[✓] bind(3, {sa_family=AF_INET, sin_port=htons(80), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
[✓] listen(3, 0) = 0
[✓] accept(3, NULL, NULL) = 4
[?] stat(0x3, NULL) = -1 EFAULT (Bad address)
[✓] exit(0) = ?
[?] +++ exited with 0 +++

===== Result =====
[✓] Success

到现在,已经是一个可以建立连接的服务器了了。

静态响应数据-系统调用read&write

如何为尝试进行连接的客户端发送一个静态的HTTP响应?
HTTP/1.0 200 OK\r\n\r\n
This exercise is important because it teaches you how to format and deliver data over the network.

动态响应数据-系统调用open

这次,服务器程序需要对HTTP GET请求返回动态的数据。

  • read 来自HTTP请求的客户套接字
  • 解析请求内容
  • open解析到的请求的文件
  • read文件
  • write 将文件内容write回套接字
  • close关闭文件
  • exit
    (这次可以从汇编层面看到HTTP请求内容的解析过程)

解析请求内容

承接上一关在这里,accept系统调用返回的套接字文件操作符在rax中
从trace记录中看到这次读到的内容包含了文件名——一个随机字符串。


所以在write之前要先想办法解析请求
解析什么?

1
int open(const char *pathname, int flags,.../* mode_t mode*/);
  • *pathname:指向需要被打开的文件的指针
  • flags:必须包含文件被打开的模式(O_RDONLY,O_WRONLY,or O_RDWR etc)
  • mode:如果文件是新创建的,指定文件权限

假设这里需要先确认下grep命令行工具的用法:
man grep
搜索-r

添加-r选项,递归地搜索整个目录下的文件内容

1
2
3
 grep -r "#define O_RDONLY" /usr/include/
/usr/include/asm-generic/fcntl.h:#define O_RDONLY 00000000
/usr/include/x86_64-linux-gnu/bits/fcntl-linux.h:#define O_RDONLY 00

在 Unix 和 Linux 系统中,fcntl.h 文件中定义的文件打开标志(如 O_RDONLY、O_WRONLY 等)通常使用八进制表示。这是因为八进制表示在位操作中更加直观。

可以查到用数字0设置O_RDONLY的值
=> mov rsi,0

以上过程由LLM完成会很方便,但这是先前的方法。

于是这里确定,要写汇编代码解析出文件名=>

  • 一个指向文件名字符串开头的指针(寄存器)
  • 一个存放着文件名长度的寄存器

完整的GET请求:
前一条read将内容读到了栈上(rsp)

1
2
3
rsp

GET /tmp/tmpdb5i6v64 HTTP/1.1\r\nHost: localhost\r\nUser-Agent: python-requests/2.32.4\r\nAccept-Encoding: gzip, deflate, zstd\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n

这里从r10开始使用更多的寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
mov r10,rsp 
while (*r10 != ' '){
r10 += 1
}
//此时r10指向第一个空格
r10 += 1
mov r11,r10
while(*r11 != ' '){
r11 += 1
}
//截断字符串
*r11 = /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
Parse_GET:
mov al,byte ptr [r10]
cmp al,' '
je Done_1
add r10,1
jmp Parse_GET
Done_1:
add r10,1
mov r11,r10

Parse_filename:
mov al,byte ptr [r11]
cmp al, ' '
je Done_2
add r11,1
jmp Parse_filename
Done_2:
mov byte ptr [r11],0

# open syscall
mov rdi,r10
mov rsi 0
mov rdx,0
mov rax,0x02
syscall

# read syscall
mov rdi,5
mov rsi,rsp
mov rdx,256
mov rax,0x00
syscall

mov r12,rax
# 此时rax中的内容是读取到的字节数(目标字符串的字节数)

#close
mov rdi,5
mov rax,0x03
syscall

# 返回响应头
mov rdi,4
lea rsi,[rip+response]
mov rdx,19
mov rax,0x01
syscall

# 返回读取内容(响应体)
mov rdi,4
mov rsi,rsp
mov rdx,r12
mov rax,0x01
syscall
……

Build-A-Web-Server
https://43.242.201.154/2025/08/01/Build-A-Web-Server/
Author
Dong
Posted on
August 1, 2025
Licensed under