GO的双解析差异+二次递归鉴权长链和CTF的吐槽(RelayDesk)
吐槽在最底下
提前大致说一下这个链子
(profile sync → 第一次 ticket 种 awaiting_reply → 第二次 continuation card 挂接 → threaded_handoff 投 admin → renderer 隐藏 iframe + postMessage → /mail/open 签 wid/rv → 4-gram oracle),中间还塞了个自定义 URL 规范化(canonicalizeEdgeAuthority 那堆 %解码 + 全角点 + .[ 截断的怪逻辑)来制造解析差异。
对于AI的全自动人工局部审计的结合,我认为仍然是一个可取的大方向,
所以在AI审计中加入自己的元素,以及学习AI的协作,开发,
我认为这是我现在学习的方向
这里打一个golang的多重链
先按照我习惯看看api
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })r.Post("/auth/login", s.handleLogin)r.Post("/api/v1/support/profile/sync", s.handleProfileSync)r.Post("/api/v1/support/tickets", s.handleTicketCreate)r.Get("/api/v1/support/tickets/{publicID}/status", s.handleTicketStatus)r.Get("/mail/inbox", s.requireAdmin(s.handleInbox))r.Post("/mail/mark-read/{id}", s.requireAdmin(s.handleMarkRead))r.Get("/mail/view/{id}", s.requireAdmin(s.handleMailView))r.Get("/mail/open/{messageID}", s.handleMailOpen)r.Get("/mail/queue/workspace", s.requireAdmin(s.handleQueueWorkspace))r.Get("/mail/queue/resume/{resumeRef}", s.handleQueueResume)r.Get("/mail/queue/assets/{resumeRef}/{slot}.js", s.handleQueueAsset)可以看到大部分都是有鉴权的,并且是s对象下的
requireadmin,跟进
func (s *server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(s.cfg.CookieName) if err != nil { http.Error(w, "auth required", http.StatusUnauthorized) return } u, err := auth.UserFromSession(r.Context(), s.db, cookie.Value) if err != nil { http.Error(w, "invalid session", http.StatusUnauthorized) return } if u.Role != "admin" { http.Error(w, "admin only", http.StatusForbidden) return } next(w, r.WithContext(context.WithValue(r.Context(), ctxUser{}, u))) }}因为在这里是s下的结构体,需要检查cookie是否是存在的,并且是否是admin。
全部验证通过之后,就存入参数,创建新的session给next
这里唯一可以追溯的是auth的UserFromSession校验
func UserFromSession(ctx context.Context, db *sql.DB, token string) (User, error) { var u User err := db.QueryRowContext(ctx, ` SELECT u.id, u.email, u.role FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = $1 AND s.expires_at > NOW() `, hashToken(token)).Scan(&u.ID, &u.Email, &u.Role) if err != nil { if errors.Is(err, sql.ErrNoRows) { return User{}, errors.New("invalid session") } return User{}, fmt.Errorf("session lookup: %w", err) } return u, nil}这里的token有hash,并且是填入式,避免了直接发包的sql注入
如此一来,在中间件鉴权目前没有找到进攻面
接下来就是为健全,在提交草稿之后会有处理草稿的中间件
func (s *server) handleTicketCreate(w http.ResponseWriter, r *http.Request) { key := s.ensureImportDraftKey(w, r) state, err := s.draftCache.Load(r.Context(), key) if err != nil { http.Error(w, "draft load failed", http.StatusInternalServerError) return }
var req ticketCreateReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if req.SubmitterEmail == "" { req.SubmitterEmail = state.SubmitterEmail } if req.Subject == "" { req.Subject = state.Subject } if req.BodyHTML == "" { req.BodyHTML = state.BodyHTML } if req.BodyText == "" { req.BodyText = state.BodyText } if req.SubmitterEmail == "" || req.Subject == "" { http.Error(w, "missing fields", http.StatusBadRequest) return } normalized, err := normalizeVerifiedSubmitter(req.SubmitterEmail) if err != nil { http.Error(w, "invalid submitter email", http.StatusBadRequest) return } req.SubmitterEmail = normalized
bundle := state.Bundle() draftSnapshot := catalog.Resolve(bundle) threadAnchor := threadAnchorForSnapshot(draftSnapshot, req.Subject) profileJSON, err := json.Marshal(bundle) if err != nil { http.Error(w, "profile encode failed", http.StatusInternalServerError) return } candidate, err := s.findThreadCandidate(r.Context(), req.SubmitterEmail, req.Subject, threadAnchor) if err != nil { http.Error(w, "retry lookup failed", http.StatusInternalServerError) return } attachedToThread := candidate.Matches(req.BodyHTML, req.Subject) if rejectContinuationRetry(candidate, req.BodyHTML, req.Subject) { writeTicketCreateResponse(w, http.StatusOK, candidate.PublicID) return } routeSnapshot := deliverySnapshotForAttempt(candidate, draftSnapshot, attachedToThread) routeMode := resolveDeliveryMode(routeSnapshot, attachedToThread) plan := resolveDeliveryPlan(routeMode) ticketID := candidate.TicketID publicID := candidate.PublicID if !attachedToThread { ticketID = uuid.NewString() publicID = freshPublicID() status := ticketStatusForState(threadAnchor) _, err = s.db.ExecContext(r.Context(), ` INSERT INTO tickets ( id, public_id, submitter_email, subject, body_html, body_text, profile_json, thread_anchor, reconcile_ready, view_token, state_token, dispatch_blob, dispatch_key, dispatch_closed_at, dispatch_settled, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, FALSE, '', '', '{}', '', NULL, FALSE, $9) `, ticketID, publicID, req.SubmitterEmail, req.Subject, req.BodyHTML, req.BodyText, string(profileJSON), threadAnchor, status) if err != nil { http.Error(w, "create ticket failed", http.StatusInternalServerError) return } } else { dispatchKey := dispatchKeyForBody(req.BodyHTML) _, err = s.db.ExecContext(r.Context(), ` UPDATE tickets SET submitter_email = $2, subject = $3, body_html = $4, body_text = $5, dispatch_key = $6, status = 'conversation_linked', dispatch_closed_at = NULL, dispatch_settled = FALSE WHERE id = $1 `, ticketID, req.SubmitterEmail, req.Subject, req.BodyHTML, req.BodyText, dispatchKey) if err != nil { http.Error(w, "update retry profile failed", http.StatusInternalServerError) return } }
_, err = s.db.ExecContext(r.Context(), ` INSERT INTO mail_jobs (id, ticket_id, submitter_email, subject, body_html, body_text, route_mode) VALUES ($1, $2, $3, $4, $5, $6, $7) `, uuid.NewString(), ticketID, req.SubmitterEmail, req.Subject, req.BodyHTML, req.BodyText, plan.RouteMode) if err != nil { http.Error(w, "queue mail failed", http.StatusInternalServerError) return }
writeTicketCreateResponse(w, http.StatusOK, publicID)}其中
bundle := state.Bundle() draftSnapshot := catalog.Resolve(bundle) threadAnchor := threadAnchorForSnapshot(draftSnapshot, req.Subject)这里的bundle也就是反馈当前状态,在 catalog.Resolve是具体的业务逻辑
主要的是在threadAnchorForSnapshot,跟进
func threadAnchorForSnapshot(snapshot catalog.Snapshot, subject string) string { if !queueSupportsWorkspace(snapshot) || !localeSupportsWorkspace(snapshot) || !auditSupportsWorkspace(snapshot) || !mailboxSupportsWorkspace(snapshot) { return "" } return handoff.ThreadAnchor(subject, snapshot.Review.Queue, snapshot.Profile.LocaleHint, snapshot.Audit.TraceToken)}这里是一个发工单id的地方,也就是说,只要这四个都满足:
queue 是 handoff
locale 是 digest
audit 是 journal
mailbox 是 managed + trusted mailbox
这样就会return
func ThreadAnchor(subject, queue, locale, trace string) string { return fnvHex(fmt.Sprintf( "%s|%s|%s|%s", NormalizeSubjectForLocale(subject, locale), QueueClass(queue), LocaleClass(locale, ""), TraceClass(trace), ))}在返回这个threadAnchor之后,status:=ticketStatusForState(threadAnchor)
func ticketStatusForState(threadAnchor string) string { if threadAnchor != "" { return "queued" } return "open"}在worker里的轮询中
if !j.TicketReady { if viewToken, stateToken, nextState, ok := deriveFollowupState(j, snapshot); ok { encodedState := encodeDispatchState(nextState) _, err = tx.ExecContext(ctx, ` UPDATE tickets SET reconcile_ready = TRUE, view_token = $2, state_token = $3, dispatch_blob = $4, dispatch_key = '', dispatch_closed_at = NULL, dispatch_settled = FALSE, status = 'awaiting_reply' WHERE id = $1 `, j.TicketID, viewToken, stateToken, encodedState) if err != nil { return err } j.TicketViewToken = viewToken j.TicketStateToken = stateToken j.TicketDispatchBlob = encodedState } }也就是说第一次 ticket 经过 worker 处理后,会从普通 queued变成awaiting_reply
并且,这个单据是可以二次追究的
看这里
routeSnapshot := deliverySnapshotForAttempt(candidate, draftSnapshot, attachedToThread) routeMode := resolveDeliveryMode(routeSnapshot, attachedToThread) plan := resolveDeliveryPlan(routeMode)跟进 resolveDeliveryMode
func resolveDeliveryMode(snapshot catalog.Snapshot, isFollowup bool) string { if isFollowup && supportsReviewFollowup(snapshot) { return "threaded_handoff" } return "standard"}这样 route mode就会变成 threaded_handoff
然后看看满足条件的
if j.RouteMode == "threaded_handoff" && j.TicketReady { recipients = resolveInternalReviewRecipients(snapshot.Profile.BridgeAddress, meta)}继续跟进resolveInternalReviewRecipients
func resolveInternalReviewRecipients(input string, meta automationMeta) []string { if !hasReferenceContext(meta) { return recipients } if !handoff.TrustedReviewMailbox(input) { return recipients }}要想投递给admin的话
route mode 是 threaded_handoff, meta 里得有合法 reference context
func buildAutomationMeta(bodyHTML, subject string, profile draft.ImportedProfile) automationMeta { raw := strings.TrimSpace(bodyHTML) if raw == "" || len(raw) > 2048 { return defaultAutomationMeta() }
card, ok := continuation.ExtractActionCard(raw) if !ok { return defaultAutomationMeta() }
meta := defaultAutomationMeta() meta.Link = card.ReferenceURL meta.Mode = card.Mode meta.Tags = card.Tags meta.ThreadID = card.ThreadID
meta.Link = strings.TrimSpace(meta.Link) if strings.TrimSpace(meta.Mode) != "inline" { return defaultAutomationMeta() } if !hasRequiredTags(meta.Tags) { return defaultAutomationMeta() } expectedThread := handoff.NormalizeThreadKey(subject) if strings.TrimSpace(meta.ThreadID) == "" || strings.TrimSpace(meta.ThreadID) != expectedThread { return defaultAutomationMeta() } rawLink := meta.Link normalizedLink, ok := handoff.NormalizeReviewLink(rawLink, profile.BridgeAddress) if !ok { return defaultAutomationMeta() } portalOrigin, ok := handoff.ReviewPortalOrigin(rawLink) if !ok { return defaultAutomationMeta() } meta.Link = normalizedLink meta.PortalOrigin = portalOrigin meta.ThreadID = expectedThread return meta}在第一次上传票据将票据的状态改变为 awaiting_reply 之后,
这样才能转接给admin 并且因为normalizedLink, ok := handoff.NormalizeReviewLink(rawLink, profile.BridgeAddress)
所以会校验链接是否合法
看看,函数normalizereviewlink
type normalizedReference struct { Source string Scheme string Authority string Path string Compat bool}
func normalizeEdgeReference(raw string) (normalizedReference, bool) { raw = strings.TrimSpace(raw) scheme, rest, found := strings.Cut(raw, "://") if !found { return normalizedReference{}, false } scheme = NormalizeValue(scheme) if scheme == "" { return normalizedReference{}, false } path := "/" authority := rest if i := strings.IndexByte(rest, '/'); i >= 0 { authority = rest[:i] path = rest[i:] } authority, compat := canonicalizeEdgeAuthority(authority) if authority == "" { return normalizedReference{}, false } return normalizedReference{ Source: raw, Scheme: scheme, Authority: authority, Path: normalizeReferencePath(path), Compat: compat, }, true}
func normalizeDeliveryReference(raw string) (normalizedReference, bool) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return normalizedReference{}, false } scheme := NormalizeValue(parsed.Scheme) if scheme == "" { return normalizedReference{}, false } authority := NormalizeValue(parsed.Hostname()) path := parsed.EscapedPath() if path == "" { path = parsed.Path } return normalizedReference{ Source: parsed.String(), Scheme: scheme, Authority: authority, Path: normalizeReferencePath(path), }, true}
func normalizeReferencePath(path string) string { path = strings.TrimSpace(path) path, _, _ = strings.Cut(path, "#") path, _, _ = strings.Cut(path, "?") if path == "" { return "/" } return path}
func referenceKey(scheme, authority, path string) string { scheme = NormalizeValue(scheme) path = normalizeReferencePath(path) if scheme == "" || path == "" { return "" } return fnvHex(fmt.Sprintf("%s|%s|%s", scheme, NormalizeValue(authority), path))}
func canonicalizeEdgeAuthority(raw string) (string, bool) { raw = strings.TrimSpace(raw) if i := strings.LastIndex(raw, "@"); i >= 0 { raw = raw[i+1:] } decoded := collapseEscapedHost(raw) usedDecode := decoded != raw raw = strings.NewReplacer("。", ".", ".", ".", "。", ".").Replace(decoded) usedCompatDot := raw != decoded compat := false if i := strings.IndexByte(raw, '['); i >= 0 { if usedDecode && usedCompatDot && i > 0 && raw[i-1] == '.' { raw = raw[:i] compat = true } } if i := strings.IndexByte(raw, ':'); i >= 0 { raw = raw[:i] } raw = strings.TrimSpace(strings.TrimSuffix(raw, ".")) if raw == "" { return "", false } ascii, err := idna.Lookup.ToASCII(strings.ToLower(raw)) if err != nil { return "", false } var b strings.Builder for _, r := range ascii { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '.' || r == '-': b.WriteRune(r) default: return "", false } } return strings.Trim(b.String(), "."), compat}
func collapseEscapedHost(raw string) string { value := strings.TrimSpace(raw) for range 2 { decoded, err := url.PathUnescape(value) if err != nil || decoded == value { break } value = decoded } return value}
func matchesSummaryPath(path string) bool { return strings.HasPrefix(path, "/notes/")}最大问题出在这
if deliveryRef.Authority == edgeRef.Authority { return "", false}必须要求第二部分解析和第一部分不一样才会,正常来说逻辑应该是
if deliveryRef.Authority == edgeRef.Authority { return "", false}这就逆天了,个人认为是没活整了,这个解析差异完全就是暴漏了
过于刻意了,对于ai来说,这个注意力是很明显的
这里解码两次
decoded := collapseEscapedHost(raw)而:
func collapseEscapedHost(raw string) string { value := strings.TrimSpace(raw) for range 2 { decoded, err := url.PathUnescape(value) if err != nil || decoded == value { break } value = decoded } return value}所以 host 里如果塞了双重编码,例如:
%255B第一次解码后变 %5B,
第二次再解码后变 [。
第二步:把全角点变成普通点
raw = strings.NewReplacer("。", ".", ".", ".", "。", ".").Replace(decoded)比如:
brief.relaydesk.local。会变成:
brief.relaydesk.local.第三步:如果出现 .[ 这种模式,就把 [ 后面全部砍掉
if i := strings.IndexByte(raw, '['); i >= 0 { if usedDecode && usedCompatDot && i > 0 && raw[i-1] == '.' { raw = raw[:i] compat = true }}这个条件很怪,意思大概是:
- 这个
[是通过解码搞出来的 - 又发生了全角点兼容替换
- 而且
[前面正好是个.
那就把 host 截断到 [ 前面。
这就相当于把:
brief.relaydesk.local.[attacker.com]截成:
brief.relaydesk.local.然后再 TrimSuffix(".",) 变成:
brief.relaydesk.local并且:
compat = true如此伪造一个可以通过检验的link到context中
<section class="message-summary" data-layout="compact"> <a class="summary-link" href="链接" data-mode="inline" data-tags="summary,activity,notes" data-thread-id="hello-world"> open </a></section>然后admin的
func (s *server) handleRender(w http.ResponseWriter, r *http.Request) { messageID := r.URL.Path[len("/mail/render/"):] tok := r.URL.Query().Get("token") var subj, bodyHTML, scriptCtx string err := s.db.QueryRowContext(r.Context(), ` SELECT subject, body_html, automation_context FROM mail_messages WHERE id = $1 AND render_token = $2 `, messageID, tok).Scan(&subj, &bodyHTML, &scriptCtx) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } var state clientState if err := json.Unmarshal([]byte(scriptCtx), &state); err != nil { state = clientState{} } stateJSON, err := json.Marshal(state) if err != nil { http.Error(w, "render failed", http.StatusInternalServerError) return } nonce := scriptNonce() w.Header().Set("Content-Security-Policy", rendererCSP(nonce, state.PortalOrigin)) w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff")
if err := s.tmpl.Execute(w, map[string]any{ "Subject": subj, "BodyHTML": bodyHTML, "ClientStateJSON": template.JS(string(stateJSON)), "Nonce": nonce, }); err != nil { http.Error(w, "render failed", http.StatusInternalServerError) return }}并且在通信中
func (s *server) handleMailOpen(w http.ResponseWriter, r *http.Request) { setAdminSurfaceHeaders(w) messageID := chi.URLParam(r, "messageID") sig := strings.TrimSpace(r.URL.Query().Get("sig"))
var ctxJSON string err := s.db.QueryRowContext(r.Context(), ` SELECT m.automation_context FROM mail_messages m JOIN mail_inbox_items i ON i.mail_message_id = m.id JOIN users u ON u.id = i.mailbox_owner_id WHERE u.email = '[email protected]' AND m.id = $1 ORDER BY i.created_at DESC LIMIT 1 `, messageID).Scan(&ctxJSON) if err != nil { http.Error(w, "not found", http.StatusNotFound) return }
var ctx resumeContext if err := json.Unmarshal([]byte(ctxJSON), &ctx); err != nil { http.Error(w, "invalid message context", http.StatusBadRequest) return } if ctx.Link == "" || !(strings.HasPrefix(ctx.Link, "http://") || strings.HasPrefix(ctx.Link, "https://")) { http.Error(w, "invalid target", http.StatusBadRequest) return } if sig == "" || ctx.ResumePath == "" || ctx.ResumePath != handoff.ResumePath(messageID, sig) { http.Error(w, "invalid open signature", http.StatusForbidden) return } visitToken := "" if ctx.ResumeRef != "" && ctx.ResumeNonce != "" { visitToken, err = s.issueWorkspaceVisit(r.Context(), ctx.ResumeRef, ctx.ResumeNonce) if err != nil { http.Error(w, "issue restore visit failed", http.StatusInternalServerError) return } } target, err := appendResumeRef(ctx.Link, ctx.ResumeRef, visitToken) if err != nil { http.Error(w, "invalid target", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"url": target})}renderer 模板里,当你控制的 iframe 页面发:
parent.postMessage({ type: 'relaydesk:resume-ready' }, '*')renderer 就会通知父页面去打开:
ctx.resume_path这一步作用在下面一步
type Archive struct { normalized string fragments map[string]struct{}}
const ( archiveWindowSize = 4 visitTokenBytes = 12)
func NewArchive(raw string) Archive { normalized := NormalizeQuery(raw) if normalized == "" { normalized = NormalizeQuery("analyst handoff record unavailable") } out := Archive{ normalized: normalized, fragments: map[string]struct{}{}, } for start := 0; start+archiveWindowSize <= len(normalized); start++ { fragment := NormalizeQuery(normalized[start : start+archiveWindowSize]) if fragment == "" { continue } out.fragments[fragment] = struct{}{} } return out}
func NormalizeQuery(v string) string { v = strings.ToLower(strings.TrimSpace(v)) if len(v) > 128 { return v[:128] } return v}
func NormalizeBucket(v string) string { v = strings.ToLower(strings.TrimSpace(v)) if len(v) > 40 { v = v[:40] } if v == "" { return "" } var b strings.Builder for _, r := range v { switch { case r >= 'a' && r <= 'z': b.WriteRune(r) case r >= '0' && r <= '9': b.WriteRune(r) case r == '-': b.WriteRune(r) default: return "" } } return b.String()}
func NormalizeRef(v string) string { v = strings.ToLower(strings.TrimSpace(v)) if len(v) != 16 { return "" } for _, r := range v { switch { case r >= '0' && r <= '9': case r >= 'a' && r <= 'f': default: return "" } } return v}
func NormalizeSlot(v string) string { v = strings.ToLower(strings.TrimSpace(strings.TrimSuffix(v, ".js"))) if len(v) != 8 { return "" } for _, r := range v { switch { case r >= '0' && r <= '9': case r >= 'a' && r <= 'f': default: return "" } } return v}
func NormalizeVisitToken(v string) string { v = strings.ToLower(strings.TrimSpace(v)) if len(v) != visitTokenBytes*2 { return "" } for _, r := range v { switch { case r >= '0' && r <= '9': case r >= 'a' && r <= 'f': default: return "" } } return v}
func (a Archive) Snapshot(query string) (string, []map[string]string) { query = NormalizeQuery(query) rows := []map[string]string{ { "Title": "recent-workspace", "Meta": "workspace search", "Detail": "Restored analyst workspaces are staged from compact cache bundles.", }, { "Title": "linked-activity", "Meta": "workspace summary", "Detail": "Attached notes stay collapsed until a stored context bundle is rehydrated.", }, } lead := "Search cached analyst workspaces and reopen stored context bundles." if query == "" { return lead, rows } if a.HasFragment(query) { rows = append(rows, map[string]string{ "Title": "restorable-context", "Meta": "detached note", "Detail": "A matching workspace fragment can be promoted from archived context.", }) } else { rows = append(rows, map[string]string{ "Title": "restorable-context", "Meta": "detached note", "Detail": "No archived context fragment matched the current workspace filter.", }) } return lead, rows}
func (a Archive) HasFragment(query string) bool { query = NormalizeQuery(query) if len(query) != archiveWindowSize { return false } _, ok := a.fragments[query] return ok}
func AssetSlot(ref, query, bucket, visitToken string) string { ref = NormalizeRef(ref) query = NormalizeQuery(query) bucket = NormalizeBucket(bucket) visitToken = NormalizeVisitToken(visitToken) if ref == "" || query == "" || bucket == "" || visitToken == "" { return "" } h := fnv.New32a() _, _ = io.WriteString(h, ref) _, _ = io.WriteString(h, "|") _, _ = io.WriteString(h, query) _, _ = io.WriteString(h, "|") _, _ = io.WriteString(h, bucket) _, _ = io.WriteString(h, "|") _, _ = io.WriteString(h, visitToken) return fmt.Sprintf("%08x", h.Sum32())}真正 flag 不在 zip 里,而是运行时 workspace context 文件里。
服务启动时会用 workspace.NewArchive(raw)
把这个字符串切成很多长度为 4 的片段,存进 fragments。
然后 /mail/queue/resume/{resumeRef}?rv=…&q=…
会用: lead, rows := s.archive.Snapshot(query) 返回两种文案之一: 命中:A matching workspace fragment can be promoted from archived context.
不命中:No archived context fragment matched the current workspace filter.
所以你拿到 wid/rv 后,就可以对 4 字符片段做 oracle。
也就拿到了flag
虽然如此,真的很想吐槽这种并没有太大实际作用的,跟完链子
我只是想说,现在的CTF很多时候不同以往了
单纯为了难而难,并且对于点的难也没有一个很好的指引
现在我依然认为CTF是促进网络安全的学习的
其中引人学习的成分是要大于其竞赛成分的
如此以往,新生力量是否会愈发依赖AI,而不是自己跟原理
让流逝的时间证明吧
我依旧认为,有心的是人,而不是AI
网络安全会永远存在。
部分信息可能已经过时





