AIGC标识 从源码审计到公网复测:临时文件共享平台安全加固实录

本文记录一次已获授权的自建系统安全检查。域名、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 消耗 仍需增加数量和频率限制
有效会话主动维持 有绝对时限,仍需继续评估
证书、依赖与主机补丁 需要持续检查

本轮结论只覆盖已经执行并留证的检查。安全复盘的价值在于:每个结论能回到证据,每项修复有回归测试,每次上线都能回滚。

项目源码:https://github.com/Javen881/FileShare

posted @ 2026-11-04 12:08  javen_xiao  阅读(2)  评论(0)    收藏  举报