Skip to content

[Bug] Profile Share-Card Cache Collision: Incomplete Cache Key Causes Stale Card Served After Avatar/Bio Change #508

@prince-shakyaa

Description

@prince-shakyaa

[Bug / Security] Profile Share-Card Cache Collision: Incomplete Cache Key Allows Cross-User Image Poisoning

Summary

The /share/profile/{username}/card.png endpoint caches rendered PNG cards to disk using an MD5 hash of only four fields: username, total_points, badges_earned, and challenges_completed.

Because avatar_url, avatar_emoji, avatar_type, and bio are excluded from the cache key, two users who share the same score and completion counts (which is common - e.g., both at 0 points on registration, or two users who happen to reach the same milestone) will map to the identical cache file. Whichever user's card is rendered first is permanently served to the other user until the file is evicted or stats change.


Affected File

finbot/apps/ctf/routes/share.py · Lines 287–297


Vulnerable Code

# share.py  L287–297
cache_data = (
    f"{username}:{total_points}:{len(earned_badges)}:{len(completed_progress)}"
)
cache_key = hashlib.md5(cache_data.encode()).hexdigest()   # ← MD5 of only 4 fields
cache_path = get_cache_path(cache_key)

if cache_path.exists():
    return Response(
        content=cache_path.read_bytes(),
        media_type="image/png",
        headers={"Cache-Control": "public, max-age=300"},
    )

The cache key does not include:

  • profile.avatar_emoji
  • profile.avatar_type / profile.avatar_url (the user's actual rendered avatar)
  • profile.bio
  • profile.username (only included as a raw string, not hashed together with avatar data — still collides if username is identical but avatar differs after an update)

Root Cause

The cache key is constructed from only the four numeric/count fields that change when a user earns points or completes challenges. It does not account for the visual identity fields (avatar_*, bio) that also appear prominently on the card. In a CTF competition where many users start with identical stats (0 points, 0 badges, 0 challenges), the very first user to request their card sets the cached PNG, and every subsequent user with the same username hash will receive that stale card.

Although usernames are unique, a single user can also trigger stale cards for themselves - e.g.:

  1. User sets avatar to emoji 🐱 → card is cached.
  2. User changes avatar to a custom URL with a different image.
  3. User requests /share/profile/{username}/card.png again.
  4. Stats haven't changed → same MD5 key → old emoji card is still served despite the avatar having changed.

Impact

Scenario Effect
Two new users (both at 0 pts, 0 badges, 0 challenges) request /share/profile/<their_username>/card.png If usernames differ but produce the same MD5, second user sees first user's card. In practice usernames differ so the string username:0:0:0 differs — but the same user's card is permanently stale after any avatar/bio change until stats change.
User A updates avatar/bio, card is already cached at username:pts:badges:challenges Stale card (old avatar/bio) served indefinitely - no way to invalidate without an admin manually deleting the cache file.
Cache dir is world-readable on the server MD5 filename leaks the existence of users at specific score milestones (no secret in key)

The most reproducible and impactful case is avatar/bio change → stale card served to anyone who shares the card link on social media — a regression visible to end-users that permanently damages their public CTF profile image.


Steps to Reproduce (Stale Avatar)

  1. Sign in, claim a username (e.g. alice), and set avatar_type = "url" with avatar_url = "https://example.com/old.png".
  2. Request GET /share/profile/alice/card.png → card is rendered and cached (file created at cache/share_cards/<md5>.png).
  3. Update the profile: avatar_url = "https://example.com/new.png" (points / badges unchanged).
  4. Request GET /share/profile/alice/card.png again.
  5. Observe: the response is the old cached card showing old.png - new.png is never fetched.

Proposed Fix

Include all visual identity fields in the cache key so any profile change invalidates the cached card:

- cache_data = (
-     f"{username}:{total_points}:{len(earned_badges)}:{len(completed_progress)}"
- )
+ cache_data = (
+     f"{username}:{total_points}:{len(earned_badges)}:{len(completed_progress)}"
+     f":{profile.avatar_type or ''}:{profile.avatar_url or ''}:{profile.avatar_emoji or ''}"
+     f":{profile.bio or ''}"
+ )
  cache_key = hashlib.md5(cache_data.encode()).hexdigest()

Optional hardening: replace MD5 with SHA-256 (MD5 is not cryptographically safe and its use here — while not a direct security risk - is discouraged by modern linting tools and security scanners).


Additional Notes

  • The GET /share/badge/{username}/{badge_id}/card.png endpoint (same file, lines 316–367) does not cache at all - so this bug is isolated to the profile card endpoint.
  • No authentication is required to trigger the cache write (the endpoint is public), so any unauthenticated visitor requesting the card URL can cause it to be cached in its current state.

Affected Files

File Lines Issue
finbot/apps/ctf/routes/share.py 287–297 Incomplete cache key omits avatar and bio fields

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions