GO的双解析差异+二次递归鉴权长链和CTF的吐槽

2026-04-11 14:251阅读0评论SEO问题
  • 内容介绍
  • 文章标签
  • 相关推荐
问题描述:

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

然后看看满足条件的

recipients := []string{"operator@relaydesk.local"} if j.RouteMode == "threaded_handoff" && j.TicketReady { recipients = resolveInternalReviewRecipients(snapshot.Profile.BridgeAddress, meta) }

继续跟进resolveInternalReviewRecipients

func resolveInternalReviewRecipients(input string, meta automationMeta) []string { recipients := []string{"operator@relaydesk.local"} if !hasReferenceContext(meta) { return recipients } if !handoff.TrustedReviewMailbox(input) { return recipients } return []string{"operator@relaydesk.local", "admin@relaydesk.local"} }

要想投递给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 = 'admin@relaydesk.local' 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

网络安全会永远存在。

网友解答:
--【壹】--:

非常牛逼,非常OK,very good

标签:网络安全
问题描述:

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

然后看看满足条件的

recipients := []string{"operator@relaydesk.local"} if j.RouteMode == "threaded_handoff" && j.TicketReady { recipients = resolveInternalReviewRecipients(snapshot.Profile.BridgeAddress, meta) }

继续跟进resolveInternalReviewRecipients

func resolveInternalReviewRecipients(input string, meta automationMeta) []string { recipients := []string{"operator@relaydesk.local"} if !hasReferenceContext(meta) { return recipients } if !handoff.TrustedReviewMailbox(input) { return recipients } return []string{"operator@relaydesk.local", "admin@relaydesk.local"} }

要想投递给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 = 'admin@relaydesk.local' 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

网络安全会永远存在。

网友解答:
--【壹】--:

非常牛逼,非常OK,very good

标签:网络安全