[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.:
- User sets avatar to emoji 🐱 → card is cached.
- User changes avatar to a custom URL with a different image.
- User requests
/share/profile/{username}/card.png again.
- 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)
- Sign in, claim a username (e.g.
alice), and set avatar_type = "url" with avatar_url = "https://example.com/old.png".
- Request
GET /share/profile/alice/card.png → card is rendered and cached (file created at cache/share_cards/<md5>.png).
- Update the profile:
avatar_url = "https://example.com/new.png" (points / badges unchanged).
- Request
GET /share/profile/alice/card.png again.
- 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 |
[Bug / Security] Profile Share-Card Cache Collision: Incomplete Cache Key Allows Cross-User Image Poisoning
Summary
The
/share/profile/{username}/card.pngendpoint caches rendered PNG cards to disk using an MD5 hash of only four fields:username,total_points,badges_earned, andchallenges_completed.Because
avatar_url,avatar_emoji,avatar_type, andbioare 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–297Vulnerable Code
The cache key does not include:
profile.avatar_emojiprofile.avatar_type/profile.avatar_url(the user's actual rendered avatar)profile.bioprofile.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.:
/share/profile/{username}/card.pngagain.Impact
/share/profile/<their_username>/card.pngusername:0:0:0differs — but the same user's card is permanently stale after any avatar/bio change until stats change.username:pts:badges:challengesThe 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)
alice), and setavatar_type = "url"withavatar_url = "https://example.com/old.png".GET /share/profile/alice/card.png→ card is rendered and cached (file created atcache/share_cards/<md5>.png).avatar_url = "https://example.com/new.png"(points / badges unchanged).GET /share/profile/alice/card.pngagain.old.png-new.pngis never fetched.Proposed Fix
Include all visual identity fields in the cache key so any profile change invalidates the cached card:
Additional Notes
GET /share/badge/{username}/{badge_id}/card.pngendpoint (same file, lines 316–367) does not cache at all - so this bug is isolated to the profile card endpoint.Affected Files
finbot/apps/ctf/routes/share.py