从源码审计到公网复测:临时文件共享平台安全加固实录
本文记录一次已获授权的自建系统安全检查。域名、IP、端口、账户、出口地址和服务器路径均已替换为变量。以下代码仅用于自有或明确授权的系统。
项目源码:Javen881/FileShare
1. 系统与安全边界
flowchart LR
U[公网访客] -->|随机 URL + PIN| N[Nginx 公网入口]
N --> G[访客服务]
G --> DB[(SQLite)]
G --> DATA[(文件目录)]
A[内网管理员] -->|独立端口| M[管理服务]
M --> DB
M --> DATA
X[无凭证攻击者] -. 只接触公网入口 .-> N
| 场景 | 已有条件 | 检查重点 |
|---|---|---|
| 无凭证 | 仅知道公网入口 | 登录绕过、文件泄露、管理面暴露、请求解析 |
| 有效会话 | 随机 URL、PIN、Cookie | 越权、资源滥用、会话与上传下载边界 |
2. 完整检查流程
flowchart LR
A[范围确认] --> B[源码审计]
B --> C[本地测试]
C --> D[公网无凭证检查]
D --> E[异常复现与定级]
E --> F[代码修复]
F --> G[生产部署与回滚验证]
G --> H[独立公网复测]
H --> I[日志与哈希归档]
| 检查面 | 代表项目 | 结果 |
|---|---|---|
| 基础鉴权 | 根路径、列表、会话、下载 | 403 / 401 |
| 伪造凭证 | Cookie、Bearer、来源 IP | 未绕过 |
| 管理面 | 管理路由、写操作、转发路径 | 404 / 403 |
| 路径与文件 | 穿越、配置、数据库、Git 元数据 | 404 / 401 |
| HTTP 解析 | 重复 Host、冲突长度、TE/CL | 代理层拒绝 |
| 方法与体积 | TRACE、OPTIONS、PUT、HEAD、超大请求 | 403 / 405 / 413 |
| TLS | 1.0、1.1、1.2、1.3 | 旧版拒绝,当前版可用 |
3. 源码审计结果
| 检查点 | 实现 | 结论 |
|---|---|---|
| 邀请与会话 | 安全随机数,数据库保存摘要 | 明文令牌不落库 |
| 一次性兑换 | 事务内校验与消费 | 并发兑换只能成功一次 |
| SQL | 参数绑定 | 未发现输入拼接 SQL |
| 文件落盘 | 随机 ID 作为存储名 | 原文件名不参与路径拼接 |
| 下载 | 固定格式 ID + O_NOFOLLOW |
降低穿越与符号链接风险 |
| 数据目录 | 不作为 Nginx 静态目录 | 下载必须经过鉴权 |
| 服务权限 | 普通用户 + systemd 限制 | 降低主机影响 |
4. 可复现检查代码
下面保留本轮安全检查的代码结构。所有目标均从环境变量读取,运行前必须确认授权。
4.1 统一留痕执行器:run_logged.py
#!/usr/bin/env python3
import datetime as dt
import hashlib
import json
import pathlib
import subprocess
import sys
import uuid
if len(sys.argv) < 2:
raise SystemExit("usage: run_logged.py COMMAND [ARG...]")
root = pathlib.Path("security-audit/logs")
root.mkdir(parents=True, exist_ok=True)
started = dt.datetime.now(dt.timezone.utc)
run_id = started.strftime("%Y%m%dT%H%M%S") + "-" + uuid.uuid4().hex[:8]
command = sys.argv[1:]
script = pathlib.Path(command[0])
script_hash = None
if script.is_file():
script_hash = hashlib.sha256(script.read_bytes()).hexdigest()
result = subprocess.run(command, text=True, capture_output=True)
ended = dt.datetime.now(dt.timezone.utc)
record = {
"run_id": run_id,
"started_utc": started.isoformat(),
"ended_utc": ended.isoformat(),
"command": command,
"script_sha256": script_hash,
"exit_code": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
}
(root / f"run-{run_id}.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8"
)
sys.stdout.write(result.stdout)
sys.stderr.write(result.stderr)
raise SystemExit(result.returncode)
4.2 公网鉴权边界:public_boundary_check.py
#!/usr/bin/env python3
import os
import requests
BASE = os.environ["AUDIT_BASE_URL"].rstrip("/")
PROXY = os.environ.get("AUDIT_SOCKS_PROXY")
proxies = {"http": PROXY, "https": PROXY} if PROXY else None
cases = [
("root", "GET", "/", {}, None),
("files", "GET", "/api/files", {}, None),
("session", "GET", "/api/session", {}, None),
("fake-cookie", "GET", "/api/files",
{"Cookie": "session=invalid-audit-token"}, None),
("fake-bearer", "GET", "/api/files",
{"Authorization": "Bearer invalid-audit-token"}, None),
("admin-route", "GET", "/admin/", {}, None),
("config", "GET", "/config/config.json", {}, None),
("git-head", "GET", "/.git/HEAD", {}, None),
("traversal", "GET", "/files/%2e%2e/%2e%2e/etc/passwd", {}, None),
("double-encoded", "GET", "/files/%252e%252e%252fetc%252fpasswd", {}, None),
("trace", "TRACE", "/", {}, None),
("preflight", "OPTIONS", "/api/login", {
"Origin": "https://attacker.invalid",
"Access-Control-Request-Method": "POST",
}, None),
("unauth-upload", "POST", "/api/upload", {},
{"name": "audit-marker.txt"}),
]
for name, method, path, headers, body in cases:
response = requests.request(
method, BASE + path, headers=headers, json=body,
proxies=proxies, timeout=12, verify=False,
allow_redirects=False,
)
print(f"{name}\t{response.status_code}\t{len(response.content)}")
4.3 登录输入与压缩体边界:input_boundary_check.py
#!/usr/bin/env python3
import gzip
import json
import os
import requests
BASE = os.environ["AUDIT_BASE_URL"].rstrip("/")
PROXY = os.environ.get("AUDIT_SOCKS_PROXY")
proxies = {"http": PROXY, "https": PROXY} if PROXY else None
url = BASE + "/api/login"
common = {"Origin": BASE, "Content-Type": "application/json"}
cases = [
("invalid-token-control",
json.dumps({"token": "audit-not-real", "pin": "12345678"}).encode(),
{}),
("unicode-boundary",
b'{"token":"\xed\xa0\x80","pin":"12345678"}',
{}),
("invalid-gzip",
b"not-a-gzip-stream",
{"Content-Encoding": "gzip"}),
("valid-gzip",
gzip.compress(b'{"token":"audit-not-real","pin":"12345678"}'),
{"Content-Encoding": "gzip"}),
("decoded-body-over-limit",
gzip.compress(b" " * 16384 + b"{}"),
{"Content-Encoding": "gzip"}),
]
for name, payload, extra in cases:
response = requests.post(
url, data=payload, headers=common | extra,
proxies=proxies, timeout=12, verify=False,
)
print(name, "wire_bytes=" + str(len(payload)),
"status=" + str(response.status_code),
"response_bytes=" + str(len(response.content)))
4.4 HTTP 请求歧义检查:http_framing_checks.py
#!/usr/bin/env python3
"""每个用例仅发送一个有界请求,不做请求走私利用。"""
import os
import socket
import ssl
HOST = os.environ["AUDIT_HOST"]
PORT = int(os.environ["AUDIT_PORT"])
def connect():
raw = socket.create_connection((HOST, PORT), timeout=8)
context = ssl.create_default_context()
return context.wrap_socket(raw, server_hostname=HOST)
cases = [
("conflicting-content-length",
"Content-Length: 0\r\nContent-Length: 1\r\n", b"x"),
("length-and-chunked",
"Content-Length: 5\r\nTransfer-Encoding: chunked\r\n", b"0\r\n\r\n"),
("unsupported-transfer-encoding",
"Transfer-Encoding: unsupported\r\n", b""),
("duplicate-host",
"Host: attacker.invalid\r\nContent-Length: 0\r\n", b""),
]
for label, extra, body in cases:
request = (
f"POST /api/login HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Origin: https://{HOST}:{PORT}\r\n"
"Content-Type: application/json\r\n"
"Connection: close\r\n"
f"{extra}\r\n"
).encode() + body
with connect() as conn:
conn.sendall(request)
response = b""
while len(response) < 8192:
part = conn.recv(min(2048, 8192 - len(response)))
if not part:
break
response += part
status_line = response.split(b"\r\n", 1)[0]
print(label, status_line.decode(errors="replace"))
4.5 TLS 协议检查:tls_check.sh
#!/usr/bin/env bash
set -euo pipefail
: "${AUDIT_HOST:?set AUDIT_HOST}"
: "${AUDIT_PORT:?set AUDIT_PORT}"
for version in tls1 tls1_1 tls1_2 tls1_3; do
if timeout 10 openssl s_client + -connect "${AUDIT_HOST}:${AUDIT_PORT}" + -servername "${AUDIT_HOST}" + -"${version}" </dev/null 2>&1 |
grep -qE 'Protocol *: TLS|Protocol *TLS'; then
printf '%s\taccepted\n' "${version}"
else
printf '%s\trejected\n' "${version}"
fi
done
4.6 应用安全回归测试:test_security_boundaries.py
import gzip
import pytest
@pytest.mark.parametrize("payload", [
{"token": "\ud800", "pin": "12345678"},
{"token": "short", "pin": "12345678"},
{"token": "A" * 43, "pin": "12345678"},
])
async def test_login_rejects_invalid_formats(client, payload):
response = await client.post(
"/api/login", json=payload,
headers={"Origin": "https://public.example"},
)
assert response.status == 400
@pytest.mark.parametrize("body", [
b"not-a-gzip-stream",
gzip.compress(b'{"token":"A","pin":"12345678"}'),
])
async def test_login_rejects_compressed_bodies(client, body):
response = await client.post(
"/api/login", data=body,
headers={
"Origin": "https://public.example",
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
)
assert response.status == 415
async def test_unknown_well_formed_invitation_stays_unauthorized(client):
response = await client.post(
"/api/login",
json={"token": "A" * 43, "pin": "12345678"},
headers={"Origin": "https://public.example"},
)
assert response.status == 401
4.7 修复代码
if (
not isinstance(token, str)
or not re.fullmatch(r"[A-Za-z0-9_-]{43}", token)
or not isinstance(pin, str)
or not re.fullmatch(r"[0-9]{8}", pin)
):
fail(400, "invalid credentials format")
encoding = request.headers.get("Content-Encoding", "identity").lower()
if encoding != "identity":
fail(415, "compressed request bodies are not supported")
app = web.Application(
middlewares=[security_middleware],
handler_args={"auto_decompress": False},
)
4.8 带回滚的部署脚本:deploy_security_fix.sh
#!/usr/bin/env bash
set -euo pipefail
: "${APP_DIR:?set APP_DIR}"
: "${STAGED_SERVER:?set STAGED_SERVER}"
: "${SERVICE_NAME:?set SERVICE_NAME}"
target="${APP_DIR}/app/server.py"
backup="${target}.bak.$(date -u +%Y%m%dT%H%M%SZ)"
sha256sum "${target}" "${STAGED_SERVER}"
cp --preserve=all "${target}" "${backup}"
rollback() {
cp --preserve=all "${backup}" "${target}"
systemctl restart "${SERVICE_NAME}"
}
trap rollback ERR
install -o root -g root -m 0644 "${STAGED_SERVER}" "${target}"
cd "${APP_DIR}"
".venv/bin/python" -m pytest -q
systemctl restart "${SERVICE_NAME}"
systemctl is-active --quiet "${SERVICE_NAME}"
trap - ERR
printf 'deployed; backup=%s\n' "${backup}"
5. 两个问题与修复效果
| 问题 | 修复前 | 原因 | 修复后 |
|---|---|---|---|
| Unicode 边界令牌 | 500 |
摘要前缺少格式验证 | 400 |
| 损坏 gzip 请求体 | 500 |
鉴权前自动解压 | 415 |
sequenceDiagram
participant L as 本地
participant P as 生产服务
participant W as 公网复测
L->>L: 21 项测试通过
L->>P: 核对哈希、备份、替换
P->>P: 19 项生产测试通过
P->>P: 重启并检查状态
W->>P: 7 项公网复测
P-->>W: 400 / 415 / 401 / 403
| 请求 | 修复前 | 修复后 |
|---|---|---|
| 特殊 Unicode 邀请令牌 | 500 |
400 |
| 损坏 gzip 请求体 | 500 |
415 |
| 有效 gzip 请求体 | 进入解压流程 | 415 |
| 格式正确但不存在的邀请 | 401 |
401 |
| 根路径 | 403 |
403 |
| 未登录文件列表 | 401 |
401 |
6. 结论与剩余风险
| 项目 | 当前状态 |
|---|---|
| 无凭证登录绕过 | 已测范围未发现 |
| 文件与配置泄露 | 已测范围未发现 |
| 管理端公网暴露 | 已测业务入口未发现 |
| SQL 注入 | 参数绑定,未发现拼接入口 |
| 两个无凭证 500 | 已修复并完成公网复测 |
| 极小文件数量与 inode 消耗 | 仍需增加数量和频率限制 |
| 有效会话主动维持 | 有绝对时限,仍需继续评估 |
| 证书、依赖与主机补丁 | 需要持续检查 |
本轮结论只覆盖已经执行并留证的检查。安全复盘的价值在于:每个结论能回到证据,每项修复有回归测试,每次上线都能回滚。

浙公网安备 33010602011771号