主题
服务端如何接收一个请求:从 Nginx 到你的代码
前两个阶段你一直在浏览器这一侧看世界:那些发出去的请求,到底是谁接住了它们? 这节课我们把镜头转到服务器内部,跟着
GET /user?id=1走完全程。你会发现:所有注入类漏洞,都是因为某个环节把"用户输入"当成了"代码"。
一、静态服务器与动态服务器
你名下的两个站——知识库 www.myxbw.cn 和学习站 learn.myxbw.cn——都是静态站,跑在 Vercel 上,服务器只做一件事:把提前生成好的文件原样递出去。
| 对比项 | 静态服务器 | 动态服务器 |
|---|---|---|
| 返回内容 | 硬盘上现成的 .html/.css/.js/.png | 每次请求现场计算、拼装出来的 |
| 是否执行代码 | 不执行,纯"文件快递员" | 执行后端程序(PHP/Node/Python/Java) |
| 典型代表 | Nginx 托管目录、Vercel、GitHub Pages | Nginx + PHP-FPM、Node、Flask、Spring |
| 安全面 | 较小(目录遍历、配置错误) | 大:注入、越权、上传、命令执行都在这里 |
动态站的本质是一句公式,只要"用户的输入"能被塞进"程序"里当语法用,漏洞就出现了:
text
响应 = 你的程序(用户的输入)二、一个请求进来后的完整路径
假设你的虚拟机里跑着一个博客,用户访问 http://192.168.1.100/user?id=1:
text
① 网卡收到 TCP 包
② 内核按端口号把连接交给监听该端口的进程(80 → Nginx)
③ Nginx 解析 HTTP 报文,拿到方法、路径、请求头
④ /user 命中规则 → 反向代理转发给应用;静态文件则直接返回
⑤ 应用进程(PHP-FPM / Node / Flask)路由匹配 → 找到处理函数
⑥ 函数读参数、查数据库、生成响应,沿原路返回浏览器- Web 服务器(Nginx/Apache):负责"接客"——监听端口、解析 HTTP、处理静态文件、转发动态请求。
- 反向代理与应用进程:浏览器只认识 Nginx,不知道后面有几个应用进程;真正跑业务逻辑的应用听着内部端口(如
127.0.0.1:3000),脆弱(重启断连、并发差、不会 TLS),所以需要 Nginx 在前面扛。
三、端口、0.0.0.0 与防火墙
监听地址决定了谁能连上:
| 监听写法 | 含义 | 安全性 |
|---|---|---|
127.0.0.1:3306 | 只接受本机连接 | 最安全,数据库就该这样 |
0.0.0.0:80 | 接受任何网卡来的连接(全网可达) | 必须配合防火墙 |
[::]:80 | 同上,IPv6 版本 | 同上 |
CentOS 7 虚拟机上查看和放行:
bash
ss -lntp # 看谁在监听哪些端口(-l 监听 -n 数字 -t TCP -p 进程)
firewall-cmd --permanent --add-port=80/tcp && firewall-cmd --reload # 放行 80Windows 11 主机这一侧等价的操作:
powershell
netstat -ano | findstr :80 # 查看监听中的端口和进程号
New-NetFirewallRule -DisplayName "Local Web 8000" -Direction Inbound -Protocol TCP -LocalPort 8000 -Action Allow⚠️ 你的虚拟机是 NAT 模式:它能主动访问外网,但主机访问虚拟机需要额外配端口转发。所以实验请在虚拟机内用
curl 127.0.0.1:8000自测,或直接在主机 Windows 上做。改网络模式是以后 第四阶段 搭靶场时才处理的事。
四、常见的后端形态
后端语言千差万别,但"接收请求"长得几乎一样:注册一条路由,取参数,返回响应。
php
<?php // PHP + Nginx/FPM:入口文件 index.php
$id = $_GET['id']; // 从查询字符串取参数
echo "<h1>用户 " . $id . "</h1>";python
# Python + Flask
from flask import Flask, request
app = Flask(__name__)
@app.route('/user')
def user():
return f"<h1>用户 {request.args.get('id')}</h1>" # 查询字符串
app.run(host='127.0.0.1', port=5000)js
// Node + Express
const express = require('express');
const app = express();
app.get('/user', (req, res) => res.send(`<h1>用户 ${req.query.id}</h1>`));
app.listen(3000, '127.0.0.1');java
// Java + Spring Boot
@RestController
public class UserController {
@GetMapping("/user")
public String user(@RequestParam("id") String id) { return "<h1>用户 " + id + "</h1>"; }
}看出共性了吗?框架帮你把原始的 HTTP 报文字节流,拆成了方便的 id 变量。 这份"方便"正是危险所在:$id 现在是一个你能随便摆布的值,而它来自完全不可信的互联网。
五、路由:URL 是怎么映射到代码的
"路由"就是一张查找表:路径 + 方法 → 处理函数。
| URL | 方法 | 映射到的代码 |
|---|---|---|
/ | GET | home() 首页 |
/user | GET | user() 查用户 |
/user | POST | create_user() 新建用户 |
/user/{id} | GET | show_user(id) 路径参数 |
注意第三行:同一个 URL,方法不同就是不同的代码。这就是为什么在 Burp Suite 里改请求时,改一个方法可能直接改变行为。
六、用户输入从哪些地方进入代码
注入类漏洞的统一入口,就是下面这五个位置;下面这条 curl 能一次把其中四类输入都送进你自己的本地服务,之后做实验时照着改就行:
| 来源 | 示例 | PHP 里怎么取 | Flask 里怎么取 |
|---|---|---|---|
| Query(查询串) | /user?id=1 | $_GET['id'] | request.args['id'] |
| Body(请求体) | 表单、JSON | $_POST['name'] | request.form['name'] |
| Header(请求头) | User-Agent: ... | $_SERVER['HTTP_USER_AGENT'] | request.headers['User-Agent'] |
| Cookie | Cookie: uid=1 | $_COOKIE['uid'] | request.cookies['uid'] |
| Path(路径段) | /user/1 | 重写规则里捕获 | @app.route('/user/<uid>') |
bash
curl -v "http://127.0.0.1:8000/user?id=1" -H "X-Test: hello" -H "Cookie: uid=99" -d "name=alice"记住这句话:只要某个值最终会拼进 SQL、shell 命令、HTML、模板或文件路径,它就是一个潜在漏洞入口。 后端工程师眼里的"参数",安全工程师眼里叫"可控点"。
七、日志与错误回显为什么危险
调试很方便,上线很致命:
text
Warning: mysqli::query(): (42000/1064): You have an error in your SQL syntax ...
near '1' at line 1 in /var/www/html/user.php on line 12这一行报错泄露了四件事:用了 MySQL、查询语句长什么样、代码文件的绝对路径、行号。攻击者据此就能推测表结构、试出注入点、规划下一步。日志同理:把完整 SQL(含用户输入)写进日志,等于把可控点标出来;记录密码、Cookie、Token 则让日志本身成为泄漏源;日志放在网站根目录更是直接公开下载。正确做法是对外统一模糊报错("系统繁忙,请稍后再试"),详细信息只写服务器本地日志并限制文件权限。
八、为什么校验必须写在服务端
前端校验(required、JS 正则、下拉框选项)只是体验优化,不是安全措施——浏览器里的代码和发出去的请求,全在用户控制之下:
bash
# 前端的 maxlength="5" 完全拦不住这个请求
curl -X POST http://127.0.0.1:8000/user -d "name=一个特别长特别长的字符串"改 DOM、用 curl、用 Burp Suite 拦包改包,都不需要什么高深技术。所以规则是:
- 服务端必须重新校验一切:类型、长度、范围、白名单字符;校验之后仍不能拼字符串,要进数据库就用参数化查询(见 SQL 基础);
- 校验是"关卡",参数化是"根本"——前者挡住明显异常,后者让注入在语法层面无法成立。
💡 动手实验 1:起一个最朴素的静态服务器
💡 动手实验 1:打开 PowerShell,进入一个空文件夹(如
E:\lab\http),在里面建一个index.html,写上<h1>你好,服务端</h1>。 在同一目录执行下面的命令,浏览器访问http://127.0.0.1:8000,你应该能看到自己的页面。注意这个进程一直占着窗口——"服务器"就是一个一直在运行的监听进程。 再做一步进阶:保持窗口运行,另开一个 PowerShell,执行第二行命令读它的响应头。你会看到Server: SimpleHTTP/0.6 Python/3.11这样的字段,这就是信息泄露最直观的例子:服务器主动告诉全世界它是什么软件、什么版本。
bash
python -m http.server 8000 # Windows 11 自带 Python 即可用
curl -I http://127.0.0.1:8000 # 另开窗口:只看响应头(-v 看完整往返)💡 动手实验 2:写十几行代码,接收用户输入
💡 动手实验 2:新建
server.js,把下面的代码粘进去(node -v能看到版本号即可),运行node server.js。 浏览器访问http://127.0.0.1:3000/user?id=小明,观察页面上的名字变了;再把地址改成?id=<h1>我是标题</h1>看会发生什么——这是你第一个 XSS 的种子,先只在本地这台机器上玩。 收尾时不要用浏览器,改用curl.exe "http://127.0.0.1:3000/user?id=curl"发请求,结果完全一样——这证明服务端根本不关心请求是不是浏览器发的,前端所有限制都能被绕开。
js
// 只监听 127.0.0.1,别人访问不到,可以放心做实验
const http = require('http'), url = require('url');
http.createServer((req, res) => {
const { pathname, query } = url.parse(req.url, true);
console.log(`[日志] ${req.method} ${req.url}`);
if (pathname !== '/user') { res.writeHead(404); return res.end('404 找不到'); }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<h1>你输入的用户名是:${query.id || '匿名'}</h1>`); // 直接拼进 HTML
}).listen(3000, '127.0.0.1', () => console.log('已监听 http://127.0.0.1:3000'));小结
- 静态服务器只递文件,动态服务器执行代码;后者才是漏洞高发区。
- 一个请求的路径:内核按端口交给 Web 服务器 → 反向代理 → 应用进程路由匹配 → 返回响应。
- 监听
127.0.0.1和监听0.0.0.0是天壤之别;暴露服务前先想清楚防火墙规则。 - 用户输入只有五个入口:query、body、header、cookie、path。记住这张表,后面的每个漏洞都会回到它。
- 错误回显和日志是最容易被忽视的信息泄露面:对外模糊、本地详细。
- 前端校验是体验,服务端校验 + 参数化查询才是安全。
下一步看"身份"是怎么被记住的:Cookie、Session 与身份认证,然后再动真格拆数据库:SQL 基础。
📎 本课对应知识库阅读:《SQL 注入》