Mobile wallpaper 1Mobile wallpaper 2Mobile wallpaper 3Mobile wallpaper 4
3393 字
17 分钟
GO的双解析差异链和CTF的吐槽
2026-03-30
统计加载中...

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{"[email protected]"}
if j.RouteMode == "threaded_handoff" && j.TicketReady {
recipients = resolveInternalReviewRecipients(snapshot.Profile.BridgeAddress, meta)
}

继续跟进resolveInternalReviewRecipients

func resolveInternalReviewRecipients(input string, meta automationMeta) []string {
recipients := []string{"[email protected]"}
if !hasReferenceContext(meta) {
return recipients
}
if !handoff.TrustedReviewMailbox(input) {
return recipients
}
return []string{"[email protected]", "[email protected]"}
}

要想投递给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

网络安全会永远存在。

GO的双解析差异链和CTF的吐槽
https://steins-gate.cn/posts/gogogo1/
作者
萦梦sora~Nya
发布于
2026-03-30
许可协议
Unlicensed

部分信息可能已经过时

封面
示例歌曲
示例艺术家
封面
示例歌曲
示例艺术家
0:00 / 0:00