miguan v1
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
// Package honeypot 提供「虚假后台登录界面」诱饵能力。
|
||||
//
|
||||
// 设计原则(务必遵守):
|
||||
// 1. 只返回假内容,绝不返回、绝不猜测任何真实凭据;
|
||||
// 2. 只在 flamego 的 NotFound 分流点生效 —— 即「没有任何路由匹配」的路径,
|
||||
// 因此与 /login、/register、/_/{domain}、/user/**、/api/v1/** 等真实业务
|
||||
// 路由天然零交集,不会劫持正常用户;
|
||||
// 3. 命中即短路:不查业务表、不渲染业务模板、不调用 ctx.Next()。
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CanaryToken 是所有假页面统一的溯源标记。它只会出现在诱饵响应里,
|
||||
// 一旦在别处(真实页面、错误日志)出现即可判定为蜜罐泄漏/误挂。
|
||||
const CanaryToken = "tb-hp-7f3a1c9e"
|
||||
|
||||
// maxCredentialFieldLen 限制单个凭据字段落库长度,避免超长 payload 撑爆表。
|
||||
const maxCredentialFieldLen = 512
|
||||
|
||||
// honeypotPrefixes 为段前缀匹配的诱饵路径。取自 autoban.sensitivePaths 的语义,
|
||||
// 但收敛为「与业务路由不可能同名」的一组,避免误伤。
|
||||
var honeypotPrefixes = []string{
|
||||
"/admin", "/administrator", "/wp-admin", "/wp-content", "/wp-includes",
|
||||
"/wp-login", "/wp-login.php", "/wp-cron", "/wp-trackback", "/wp-comments-post",
|
||||
"/wp-json", "/wp-config", "/wp-mail", "/wp-signup", "/wp-activate", "/wp-links-opml",
|
||||
"/wp-blog-header", "/wp-load", "/wp-settings", "/wp-load.php", "/wp-settings.php",
|
||||
"/xmlrpc", "/console", "/manager", "/manager/html", "/phpmyadmin", "/pma",
|
||||
"/cpanel", "/plesk", "/webadmin", "/adminer", "/shell", "/cmd", "/eval",
|
||||
"/webshell", "/cgi-bin", "/shell.php", "/admin.php", "/login.php",
|
||||
"/.ssh", "/.aws", "/.env", "/.git", "/.svn", "/.hg", "/.docker",
|
||||
"/.config", "/.npm", "/.kube", "/.vscode", "/.idea",
|
||||
"/actuator", "/swagger", "/swagger-ui", "/openapi", "/v2/api-docs", "/v3/api-docs",
|
||||
"/api-docs", "/druid", "/nacos", "/solr", "/elastic", "/apollo",
|
||||
"/server-status", "/server-info", "/info.php", "/phpinfo", "/test.php",
|
||||
"/backup", "/backups", "/dump", "/database", "/db", "/sql", "/mysql",
|
||||
"/config", "/configuration", "/vendor", "/storage", "/tmp", "/temp",
|
||||
"/upload", "/uploads", "/files", "/log", "/logs",
|
||||
}
|
||||
|
||||
// honeypotFragments 为任意位置子串匹配的诱饵路径片段(大小写不敏感)。
|
||||
var honeypotFragments = []string{
|
||||
"/wp-admin", "/wp-login", "/wp-content", "/wp-includes", "/wp-json",
|
||||
"/wp-config", "/xmlrpc.php", "/wordpress", "/phpmyadmin", "/cgi-bin/",
|
||||
"/.git/", "/.env", "/.aws/", "/.ssh/", "/actuator/", "/swagger",
|
||||
"/druid/", "/solr/", "/nacos/", "/vuln", "/shell?", "/cmd?",
|
||||
"/.bak", "/.old", "/.orig", "/.save", "/.swp", "/.zip", "/.tar.gz", "/.sql",
|
||||
}
|
||||
|
||||
// forbiddenPrefixes 是业务路由前缀:即便有人往诱饵表里塞了这些前缀,
|
||||
// 匹配时也会被强制剔除,构成第二道保险。
|
||||
var forbiddenPrefixes = []string{
|
||||
"/login", "/register", "/logout", "/signup", "/sign-in", "/sign_up",
|
||||
"/user", "/api", "/_", "/assets", "/static", "/css", "/js", "/images",
|
||||
"/favicon", "/robots", "/sitemap", "/feed", "/rss", "/about", "/rules",
|
||||
"/sponsor", "/change-logs", "/maintenance-mode", "/pixel",
|
||||
}
|
||||
|
||||
// Options 是蜜罐行为配置。
|
||||
type Options struct {
|
||||
// Enabled 为false 时 Honeypot() 恒返回 nil(不接管任何路径)。
|
||||
Enabled bool
|
||||
// ExtraPaths 追加自定义诱饵路径(支持段前缀语义,写成 "/a/b" 即前缀匹配)。
|
||||
ExtraPaths []string
|
||||
// MaxPayloadBytes 单次可记录的用户名/密码等字段长度上限。
|
||||
MaxPayloadBytes int
|
||||
}
|
||||
|
||||
func (o Options) maxField() int {
|
||||
if o.MaxPayloadBytes > 0 {
|
||||
return o.MaxPayloadBytes
|
||||
}
|
||||
return maxCredentialFieldLen
|
||||
}
|
||||
|
||||
// Honeypot 是蜜罐匹配器,构造后不可变,可安全并发使用。
|
||||
type Honeypot struct {
|
||||
enabled bool
|
||||
prefix map[string]bool
|
||||
maxField int
|
||||
}
|
||||
|
||||
// New 构造蜜罐匹配器。ExtraPaths 会参与前缀匹配。
|
||||
func New(o Options) *Honeypot {
|
||||
h := &Honeypot{
|
||||
enabled: o.Enabled,
|
||||
prefix: make(map[string]bool, len(honeypotPrefixes)+len(o.ExtraPaths)),
|
||||
maxField: o.maxField(),
|
||||
}
|
||||
for _, p := range honeypotPrefixes {
|
||||
h.prefix[normalize(p)] = true
|
||||
}
|
||||
for _, p := range o.ExtraPaths {
|
||||
if p = normalize(p); p != "" {
|
||||
h.prefix[p] = true
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Enabled 报告蜜罐是否启用。
|
||||
func (h *Honeypot) Enabled() bool { return h != nil && h.enabled }
|
||||
|
||||
// normalize 归一化路径:小写、合并重复斜杠、去掉结尾斜杠(根除外)。
|
||||
func normalize(p string) string {
|
||||
p = strings.ToLower(strings.TrimSpace(p))
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
// 折叠重复斜杠
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
for strings.HasSuffix(p, "/") && len(p) > 1 {
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Match 判断路径是否应被蜜罐接管。
|
||||
//
|
||||
// 归一化步骤:去查询串、URL 反解码一次(对抗 %2f 编码绕过)、折叠斜杠、
|
||||
// 去掉尾部 /index.html 之类不影响结果的尾斜杠。
|
||||
func (h *Honeypot) Match(rawPath string) bool {
|
||||
if !h.Enabled() {
|
||||
return false
|
||||
}
|
||||
p := normalizePath(rawPath)
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
for _, f := range forbiddenPrefixes {
|
||||
if p == f || strings.HasPrefix(p, f+"/") {
|
||||
// 业务路由,绝不接管
|
||||
return false
|
||||
}
|
||||
}
|
||||
if _, ok := h.prefix[p]; ok {
|
||||
return true
|
||||
}
|
||||
// 段前缀:/wp-admin/install.php 命中 /wp-admin
|
||||
segs := strings.Split(p, "/")
|
||||
for i := 1; i < len(segs); i++ {
|
||||
joined := strings.Join(segs[:i+1], "/")
|
||||
if h.prefix[joined] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, f := range honeypotFragments {
|
||||
if strings.Contains(p, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePath(raw string) string {
|
||||
p := raw
|
||||
// 去查询串与 fragment(畸形请求可能没有 '?',用 Cut 兜底)
|
||||
if i := strings.IndexAny(p, "?#"); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
// 反解码一次;失败(非法百分号编码)则用原串继续。
|
||||
if dec, err := url.PathUnescape(p); err == nil && dec != "" {
|
||||
p = dec
|
||||
}
|
||||
p = strings.ReplaceAll(p, "\\", "/")
|
||||
return normalize(p)
|
||||
}
|
||||
|
||||
// Credentials 是从一次诱饵提交中提取出的破解信息。
|
||||
type Credentials struct {
|
||||
Username string
|
||||
Password string
|
||||
Fields map[string]string
|
||||
RawBody string
|
||||
HadFields bool
|
||||
}
|
||||
|
||||
// credentialKeys 是常见的用户名/密码字段名(表单 POST 与 JSON body 均适用)。
|
||||
var credentialKeys = map[string]bool{
|
||||
"log": true, "pwd": true, "username": true, "user": true, "pass": true,
|
||||
"password": true, "passwd": true, "pw": true, "email": true, "name": true,
|
||||
"account": true, "login": true, "wp_user": true, "user_login": true,
|
||||
"wp_password": true, "uname": true, "nickname": true, "id": true, "token": true,
|
||||
}
|
||||
|
||||
// ExtractCredentials 从请求中提取破解凭据。
|
||||
//
|
||||
// 同时支持三类来源,按优先级合并:
|
||||
// 1. URL 查询串 / POST 表单字段(application/x-www-form-urlencoded、multipart);
|
||||
// 2. JSON body({"username":"admin","password":"123456"});
|
||||
// 3. Basic Auth 头。
|
||||
//
|
||||
// 永不 panic、永不因解析失败阻断响应。
|
||||
func (h *Honeypot) ExtractCredentials(r *http.Request) Credentials {
|
||||
out := Credentials{Fields: map[string]string{}}
|
||||
limit := maxCredentialFieldLen
|
||||
if h != nil && h.maxField > 0 {
|
||||
limit = h.maxField
|
||||
}
|
||||
put := func(k, v string) {
|
||||
k = strings.ToLower(strings.TrimSpace(k))
|
||||
v = strings.TrimSpace(v)
|
||||
if k == "" {
|
||||
return
|
||||
}
|
||||
if len(v) > limit {
|
||||
v = v[:limit]
|
||||
}
|
||||
if _, exists := out.Fields[k]; !exists {
|
||||
out.Fields[k] = v
|
||||
}
|
||||
switch k {
|
||||
case "log", "username", "user", "login", "user_login", "email", "account", "uname", "wp_user", "nickname":
|
||||
if out.Username == "" {
|
||||
out.Username = v
|
||||
}
|
||||
case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
|
||||
if out.Password == "" {
|
||||
out.Password = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 查询串
|
||||
if r != nil && r.URL != nil {
|
||||
for k, vs := range r.URL.Query() {
|
||||
if len(vs) > 0 {
|
||||
put(k, vs[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r == nil || r.Body == nil {
|
||||
out.finish()
|
||||
return out
|
||||
}
|
||||
if r.Body != nil {
|
||||
defer func() { _ = r.Body.Close() }()
|
||||
}
|
||||
|
||||
// 全量读取(bodylimit 中间件已对非 GET 请求施加上限,这里再做一层防御)
|
||||
buf := make([]byte, 0, 1024)
|
||||
tmp := make([]byte, 4096)
|
||||
reader := r.Body
|
||||
for {
|
||||
n, err := reader.Read(tmp)
|
||||
if n > 0 {
|
||||
if len(buf)+n > limit*8 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
break
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
raw := string(buf)
|
||||
if len(raw) > limit {
|
||||
raw = raw[:limit]
|
||||
}
|
||||
out.RawBody = raw
|
||||
|
||||
ct := strings.ToLower(r.Header.Get("Content-Type"))
|
||||
switch {
|
||||
case strings.Contains(ct, "json"):
|
||||
parseJSONCredentials(raw, put)
|
||||
default:
|
||||
// ParseQuery 遇到非法百分号编码会返回 error,但仍会给出已成功解析的部分字段。
|
||||
// 蜜罐要的是「尽可能多拿到凭据」,故忽略 error 继续用部分结果。
|
||||
vals, _ := url.ParseQuery(raw)
|
||||
if len(vals) > 0 {
|
||||
for k, vs := range vals {
|
||||
if len(vs) > 0 {
|
||||
put(k, vs[0])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 完全不像表单时再按 JSON 试一次。
|
||||
parseJSONCredentials(raw, put)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Basic Auth
|
||||
if user, pass, ok := r.BasicAuth(); ok {
|
||||
put("username", user)
|
||||
put("password", pass)
|
||||
}
|
||||
|
||||
out.finish()
|
||||
return out
|
||||
}
|
||||
|
||||
// finish 依据 Fields 补齐 Username/Password,并置 HadFields。
|
||||
func (c *Credentials) finish() {
|
||||
for k, v := range c.Fields {
|
||||
if credentialKeys[k] && v != "" {
|
||||
c.HadFields = true
|
||||
}
|
||||
}
|
||||
if c.Username == "" || c.Password == "" {
|
||||
for k, v := range c.Fields {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if c.Username == "" && !isPasswordKey(k) {
|
||||
if credentialKeys[k] {
|
||||
c.Username = v
|
||||
}
|
||||
}
|
||||
if c.Password == "" && isPasswordKey(k) {
|
||||
c.Password = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.Username != "" || c.Password != "" {
|
||||
c.HadFields = true
|
||||
}
|
||||
}
|
||||
|
||||
func isPasswordKey(k string) bool {
|
||||
switch k {
|
||||
case "pwd", "password", "passwd", "pw", "pass", "wp_password", "token":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// jsonPairRe 匹配 JSON 扁平字段:"key": value(value 可为字符串/数字/布尔/null)。
|
||||
// 只取第一层深度已足够覆盖 {"username":"admin","password":123456} 这类破解 payload;
|
||||
// 嵌套对象/数组的内部内容不作为凭据,避免把无关结构误记成密码。
|
||||
var jsonPairRe = regexp.MustCompile(`"([A-Za-z0-9_.\-]{1,64})"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?|true|false|null)`)
|
||||
|
||||
// parseJSONCredentials 极简 JSON 字段提取,容忍不规范 body,永不 panic。
|
||||
func parseJSONCredentials(raw string, put func(k, v string)) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || (raw[0] != '{' && raw[0] != '[') {
|
||||
return
|
||||
}
|
||||
for _, m := range jsonPairRe.FindAllStringSubmatch(raw, -1) {
|
||||
k := strings.TrimSpace(m[1])
|
||||
v := strings.TrimSpace(m[2])
|
||||
if len(v) >= 2 && v[0] == '"' {
|
||||
if s, err := strconv.Unquote(v); err == nil {
|
||||
v = s
|
||||
} else {
|
||||
v = strings.Trim(v, `"`)
|
||||
}
|
||||
}
|
||||
if v == "null" {
|
||||
v = ""
|
||||
}
|
||||
put(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Describer 返回 Honeypot 的可读描述,供 CLI / 排障使用。
|
||||
func (h *Honeypot) Describer() string {
|
||||
if h == nil || !h.enabled {
|
||||
return "disabled"
|
||||
}
|
||||
keys := make([]string, 0, len(h.prefix))
|
||||
for k := range h.prefix {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return path.Join(keys...)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatch(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
|
||||
hit := []string{
|
||||
"/wp-admin", "/wp-admin/install.php", "/wp-login.php", "/wp-content/plugins/",
|
||||
"/.ssh/id_rsa", "/.env", "/.git/config", "/.aws/credentials",
|
||||
"/administrator", "/phpmyadmin", "/actuator/env", "/swagger-ui.html",
|
||||
"/xmlrpc.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php",
|
||||
"/wp-admin/../wp-content/x.php",
|
||||
}
|
||||
for _, p := range hit {
|
||||
if !h.Match(p) {
|
||||
t.Errorf("Match(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 业务路由必须一律不接管 —— 这是蜜罐的红线。
|
||||
miss := []string{
|
||||
"/", "/login", "/register", "/logout", "/_/alice", "/user", "/user/settings",
|
||||
"/api/v1/questions", "/static/css/base.css", "/favicon.ico", "/robots.txt",
|
||||
"/about", "/rules", "/sponsor", "/change-logs.html", "/vote/abc",
|
||||
"/explore", "/i18n/en",
|
||||
}
|
||||
for _, p := range miss {
|
||||
if h.Match(p) {
|
||||
t.Errorf("Match(%q) = true, want false(业务路由不得被蜜罐接管)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchDisabled(t *testing.T) {
|
||||
h := New(Options{Enabled: false})
|
||||
if h.Match("/wp-admin") {
|
||||
t.Error("蜜罐未启用时不应匹配任何路径")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchEncoded(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
// %2f 编码绕过
|
||||
if !h.Match("/wp-admin%2f..%2fetc") {
|
||||
t.Error("编码后的 wp-admin 路径应被匹配")
|
||||
}
|
||||
// 大小写
|
||||
if !h.Match("/WP-ADMIN") {
|
||||
t.Error("大小写不同的 WP-ADMIN 应被匹配")
|
||||
}
|
||||
// 查询串不应影响判定
|
||||
if !h.Match("/wp-login.php?redirect_to=/wp-admin") {
|
||||
t.Error("带查询串的 wp-login.php 应被匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraPaths(t *testing.T) {
|
||||
h := New(Options{Enabled: true, ExtraPaths: []string{"/secret-backdoor"}})
|
||||
if !h.Match("/secret-backdoor") {
|
||||
t.Error("自定义诱饵路径应被匹配")
|
||||
}
|
||||
if !h.Match("/secret-backdoor/x.php") {
|
||||
t.Error("自定义诱饵路径的子路径应被匹配")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsForm(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader("log=admin&pwd=admin123&redirect_to=%2Fwp-admin%2F&wp-submit=Log+In"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" {
|
||||
t.Errorf("Username = %q, want admin", creds.Username)
|
||||
}
|
||||
if creds.Password != "admin123" {
|
||||
t.Errorf("Password = %q, want admin123", creds.Password)
|
||||
}
|
||||
if !creds.HadFields {
|
||||
t.Error("HadFields = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsJSON(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader(`{"username":"root","password":"toor","extra":1}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "root" {
|
||||
t.Errorf("Username = %q, want root", creds.Username)
|
||||
}
|
||||
if creds.Password != "toor" {
|
||||
t.Errorf("Password = %q, want toor", creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsQuery(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/wp-login.php?log=admin&pwd=secret", nil)
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" || creds.Password != "secret" {
|
||||
t.Errorf("got %q/%q, want admin/secret", creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsBasicAuth(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
|
||||
req.SetBasicAuth("admin", "hunter2")
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" || creds.Password != "hunter2" {
|
||||
t.Errorf("got %q/%q, want admin/hunter2", creds.Username, creds.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsEmpty(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
req := httptest.NewRequest(http.MethodGet, "/wp-admin", nil)
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.HadFields {
|
||||
t.Error("空请求不应标记为携带凭据")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCredentialsMalformed(t *testing.T) {
|
||||
h := New(Options{Enabled: true})
|
||||
// 畸形 body 不应 panic
|
||||
req := httptest.NewRequest(http.MethodPost, "/wp-login.php",
|
||||
strings.NewReader("%zz&log=admin&&=="))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
creds := h.ExtractCredentials(req)
|
||||
if creds.Username != "admin" {
|
||||
t.Errorf("Username = %q, want admin(畸形 body 仍应尽量提取)", creds.Username)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package honeypot
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LoginPageVars 是假登录页的渲染参数。
|
||||
type LoginPageVars struct {
|
||||
// Action 是表单提交地址(当前诱饵路径本身)。
|
||||
Action string
|
||||
// Title / Heading 是页面标题与表单标题。
|
||||
Title string
|
||||
Heading string
|
||||
// Notice 是提交失败后的提示语。
|
||||
Notice string
|
||||
// Redir 为原始请求的查询串(WordPress 风格的 ?redirect_to=),原样回填。
|
||||
Redir string
|
||||
}
|
||||
|
||||
// RenderLoginPage 输出仿 WordPress 后台风格的假登录页(200,text/html)。
|
||||
//
|
||||
// 页面刻意包含:
|
||||
// - 隐藏 canary 标记(HTML 注释 + meta),便于日后溯源;
|
||||
// - 经典 WP 字段名 log / pwd / redirect_to —— 让暴力破解脚本原样提交,
|
||||
// 我们就能拿到最干净的用户名与密码对;
|
||||
// - 一个 nginx/Apache 风格的 404 页脚,暗示「这个后台真的存在」,
|
||||
// 但正文是纯静态 HTML,不泄露任何版本、路径或真实凭据。
|
||||
func RenderLoginPage(vars LoginPageVars) []byte {
|
||||
action := html.EscapeString(vars.Action)
|
||||
title := vars.Title
|
||||
if title == "" {
|
||||
title = "Log In ‹ WordPress"
|
||||
}
|
||||
heading := vars.Heading
|
||||
if heading == "" {
|
||||
heading = "WordPress"
|
||||
}
|
||||
notice := ""
|
||||
if vars.Notice != "" {
|
||||
notice = `<div id="login_error">` + html.EscapeString(vars.Notice) + `</div>`
|
||||
}
|
||||
redir := html.EscapeString(vars.Redir)
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(4096)
|
||||
b.WriteString(`<!DOCTYPE html>` + "\n")
|
||||
b.WriteString(`<!-- canary: ` + CanaryToken + ` -->` + "\n")
|
||||
b.WriteString(`<html lang="en-US">` + "\n")
|
||||
b.WriteString(`<head>` + "\n")
|
||||
b.WriteString(`<meta charset="UTF-8" />` + "\n")
|
||||
b.WriteString(`<meta name="viewport" content="width=device-width, initial-scale=1" />` + "\n")
|
||||
b.WriteString(`<meta name="generator" content="TamaBox honeypot ` + CanaryToken + `" />` + "\n")
|
||||
b.WriteString(`<title>` + html.EscapeString(title) + `</title>` + "\n")
|
||||
// 静态内联样式,避免蜜罐依赖站点静态资源(也避免污染真实缓存)
|
||||
b.WriteString(`<style>` + honeypotCSS + `</style>` + "\n")
|
||||
b.WriteString(`</head>` + "\n")
|
||||
b.WriteString(`<body class="login">` + "\n")
|
||||
b.WriteString(`<div id="login">` + "\n")
|
||||
b.WriteString(`<h1><a href="` + action + `" title="` + html.EscapeString(heading) + `">` + html.EscapeString(heading) + `</a></h1>` + "\n")
|
||||
b.WriteString(notice + "\n")
|
||||
b.WriteString(`<form name="loginform" id="loginform" action="` + action + `" method="post" enctype="application/x-www-form-urlencoded">` + "\n")
|
||||
b.WriteString(`<p><label for="user_login">Username<br /><input type="text" name="log" id="user_login" class="input" value="" size="20" autocapitalize="off" autocomplete="username" /></label></p>` + "\n")
|
||||
b.WriteString(`<p><label for="user_pass">Password<br /><input type="password" name="pwd" id="user_pass" class="input" value="" size="20" autocomplete="current-password" /></label></p>` + "\n")
|
||||
b.WriteString(`<p class="forgetmenot"><input name="rememberme" type="checkbox" id="rememberme" value="forever" /> Remember Me</p>` + "\n")
|
||||
b.WriteString(`<p class="submit"><input type="submit" name="wp-submit" id="wp-submit" class="button button-primary button-large" value="Log In" /></p>` + "\n")
|
||||
b.WriteString(`<input type="hidden" name="redirect_to" value="` + redir + `" />` + "\n")
|
||||
b.WriteString(`<input type="hidden" name="testcookie" value="1" />` + "\n")
|
||||
b.WriteString(`</form>` + "\n")
|
||||
b.WriteString(`<p id="nav"><a href="` + action + `?action=lostpassword">Lost your password?</a> | <a href="` + action + `?action=register">Register</a></p>` + "\n")
|
||||
b.WriteString(`</div>` + "\n")
|
||||
b.WriteString(`<div id="footer"><p>` + html.EscapeString(NotFoundFooter) + `</p></div>` + "\n")
|
||||
b.WriteString(`</body>` + "\n")
|
||||
b.WriteString(`</html>` + "\n")
|
||||
return []byte(b.String())
|
||||
}
|
||||
|
||||
const honeypotCSS = `html{background:#f0f0f1}body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:14px;color:#3c434a}
|
||||
#login{border:1px solid #dcdcde;background:#fff;width:320px;margin:5em auto;padding:26px 24px 46px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||||
#login h1{text-align:center;margin:0 0 1em;font-size:24px;font-weight:400}
|
||||
#login h1 a{color:#444;text-decoration:none}
|
||||
#login form{margin-top:16px}
|
||||
#login label{display:block;font-size:14px}
|
||||
#login input[type=text],#login input[type=password]{width:100%;padding:5px 8px;border:1px solid #ddd;border-radius:3px;box-sizing:border-box;margin-top:4px}
|
||||
#login .button{width:100%;padding:7px 12px;background:#2271b1;border:1px solid #2271b1;border-radius:3px;color:#fff;cursor:pointer;font-size:13px}
|
||||
#login .submit{margin:16px 0 0}
|
||||
#login_error{border-left:4px solid #d63638;background:#fcf0f1;padding:10px 12px;margin:12px 0}
|
||||
#nav,.forgetmenot{font-size:13px;color:#50575e}
|
||||
#footer{text-align:center;color:#646970;font-size:12px;margin:24px auto;max-width:320px}
|
||||
`
|
||||
|
||||
// NotFoundFooter 是 404 假页脚,用于给扫描器「后台存在但资源不存在」的错觉。
|
||||
const NotFoundFooter = "The requested resource was not found on this server."
|
||||
|
||||
// LoginFailureNotice 是提交凭据后的统一提示(不区分「用户不存在」与「密码错误」,
|
||||
// 避免因响应差异让爆破脚本调整策略;同时这也是虚假后台「存在」的证据)。
|
||||
const LoginFailureNotice = "ERROR: Invalid username or password."
|
||||
|
||||
// WriteLoginPage 将假登录页写入响应。
|
||||
func WriteLoginPage(w http.ResponseWriter, vars LoginPageVars) {
|
||||
body := RenderLoginPage(vars)
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.Set("Cache-Control", "no-store, no-cache, must-revalidate")
|
||||
// 诱饵页面绝不进入任何共享/边缘缓存,也不被中间层改写。
|
||||
h.Set("X-Robots-Tag", "noindex, nofollow")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
Reference in New Issue
Block a user