Common Image Upload Bugs and How to Catch Them

Common Image Upload Bugs and How to Catch Them

March 22, 2026 · 16 min read · Common Issues

Common Image Upload Bugs and How to Catch Them

Image upload functionality is a deceptively simple feature that hides a surprising number of failure modes. When users can attach pictures to a profile, a product listing, or a chat, the client‑side form, the network layer, and the server‑side processing pipeline all interact. A defect in any of those layers can lead to broken UI, security exposure, or degraded performance. This guide walks through the most common image‑upload bugs, explains why they arise, shows how they appear to users, and gives concrete steps to reproduce, detect, and fix each issue. You’ll also find a test matrix that combines manual checks with automated techniques, plus a short checklist you can bookmark for release‑day verification.

1. Understanding Image Upload Workflows

Typical client‑side flow

When a user selects a file, the browser creates a File object and appends it to a FormData instance. The form is then sent via XMLHttpRequest or fetch using multipart/form-data. Modern SPAs may also drag‑and‑drop the file onto a zone, which triggers the same underlying API. Client‑side validation (if any) usually checks the file extension, MIME type reported by the browser, and size before the request leaves the page.

Server‑side processing pipeline

On the backend, the request is parsed into parts. A typical flow looks like:

  1. Parsing – extract the raw byte stream and headers (Content‑Disposition, Content‑Type).
  2. Pre‑validation – apply size limits, allowed extensions, and MIME‑type whitelist.
  3. Storage – write the stream to a temporary location, then move it to a permanent bucket or database.
  4. Post‑processing – generate thumbnails, strip or preserve EXIF, run virus scans, or push to a CDN.
  5. Response – return a URL or identifier to the client.

Each step is a potential fault line. Missing validation at step 2, insecure handling at step 3, or faulty image libraries at step 4 are the most common sources of bugs.

Where bugs commonly hide

Understanding this map helps you target tests where the risk is highest.

2. Bug Pattern 1: File Type Mismatch / Missing Validation

Why it happens

Developers often rely on the file extension supplied by the client (image.jpg) or the MIME type reported by the browser (image/jpeg). Neither is trustworthy; a malicious user can rename a .exe to .jpg or craft a request with a falsified Content-Type. If the server only checks those attributes, the unsafe file passes through.

User impact

Reproduce & detect

Manual

  1. Open DevTools, locate the upload request, and change the Content-Type to application/octet-stream while keeping a .png extension.
  2. Submit the request and verify whether the server stores the file without rejecting it.

Automated


curl -F "avatar=@/path/to/malicious.exe;type=image/jpeg" \
     -H "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW" \
     https://example.com/api/upload

Check the response: a 200 OK with a URL indicates the bug. Add an assertion in a test suite that expects a 4xx error for non‑image MIME types.

Fix & prevention

3. Bug Pattern 2: Size Limit Bypass / Oversized Uploads

Why it happens

Size limits are often enforced only in client‑side JavaScript (if (file.size > MAX) { … }). Network‑level tools or a simple HTML form without JS can bypass this check. Even when the server checks Content-Length, some frameworks truncate the stream after reading the declared length, allowing an attacker to send more data than the header indicates (chunked encoding abuse).

User impact

Reproduce & detect

Manual

Disable JavaScript, open the upload form, and select a file that is obviously too large (e.g., a 200 MB dummy file). Submit and observe whether the server accepts it.

Automated


import requests
url = "https://example.com/api/upload"
files = {"file": ("big.img", b"0"*150*1024*1024, "application/octet-stream")}
r = requests.post(url, files=files)
print(r.status_code, r.text)   # expect 413 or 400 if limit works

A status code other than 4xx indicates the bug.

Fix & prevention

4. Bug Pattern 3: Filename Injection / Path Traversal

Why it happens

When the server uses the client‑supplied filename directly to construct a storage path (/uploads/ + filename), an attacker can include ../ sequences or absolute paths (/etc/passwd). If the upload directory is not properly chrooted or the process runs with excessive privileges, the file may overwrite critical system files or be placed in a location that is later served as static content.

User impact

Reproduce & detect

Manual

Using curl, send a filename with directory traversal:


curl -F "photo=@/etc/passwd;filename=../../../tmp/evil.jpg" \
     https://example.com/api/upload

Check whether a file appears in /tmp/evil.jpg on the server (you may need shell access or a log that records the write path).

Automated

Write a test that attempts a set of malicious filenames and asserts that the stored path is normalized to a safe basename:


String[] badNames = {"../../../etc/passwd", "C:\\Windows\\system32\\config\\sam", "/absolute/path/img.png"};
for (String name : badNames) {
    MockMultipartFile file = new MockMultipartFile("file", name, "image/jpeg", new byte[]{0});
    mockMvc.perform(multipart("/api/upload").file(file))
           .andExpect(status().isBadRequest())
           .andExpect(jsonPath("$.error").value("Invalid filename"));
}

Fix & prevention

5. Bug Pattern 4: Metadata Exploits (EXIF, GPS, malicious scripts)

Why it happens

Image formats such as JPEG and TIFF allow embedded metadata blocks (EXIF, IPTC, XMP). Libraries that blindly copy these blocks into the output image or into HTML can introduce security issues. For example, an EXIF block containing JavaScript can be reflected in an tag’s alt attribute if the server copies the UserComment field without escaping. GPS coordinates can reveal private locations, and malformed EXIF can cause certain image processors to crash or allocate excessive memory.

User impact

Reproduce & detect

Manual

Use exiftool to inject a script:


exiftool -Comment='<script>alert(1)</script>' -o test.jpg original.jpg

Upload test.jpg. Inspect the resulting HTML (view source) for the script tag appearing unescaped.

Automated


import piexif, requests
def make_exif_xss():
    exif_dict = {"0th":{}, "Exif":{}, "GPS":{}, "1st":{}, "thumbnail":None}
    exif_dict["0th"][piexif.ImageIFD.ImageDescription] = b'<script>alert(1)</script>'
    exif_bytes = piexif.dump(exif_dict)
    img = Image.new('RGB', (100,100), color='red')
    img.save("xss.jpg", "jpeg", exif=exif_bytes)
    return open("xss.jpg", "rb")
files = {"file": ("xss.jpg", make_exif_xss(), "image/jpeg")}
r = requests.post("https://example.com/api/upload", files=files)
assert "<script>" not in r.text   # expect sanitized output

Fix & prevention

6. Bug Pattern 5: Race Conditions / TOCTOU in Temp File Handling

Why it happens

A typical pattern:

  1. Save uploaded bytes to a temporary file with a predictable name (/tmp/upload_12345).
  2. Move the file to its final location after validation.

If an attacker can predict or guess the temporary name and replace the file between steps 1 and 2 (a classic TOCTOU – Time‑Of‑Check‑Time‑Of‑Use), they can cause the server to move a malicious file into the upload directory.

User impact

Reproduce & detect

Manual

Create a symlink attack:


ln -s /etc/passwd /tmp/upload_$(pgrep -f "upload")   # guess the PID

Then upload a small image; if the server follows the symlink, /etc/passwd will be overwritten.

Automated (using a test harness that controls timing):


@Test
public void tempFileRace() throws Exception {
    // Mock the file‑creation service to return a predictable path
    when(tempFileService.createTempFile()).thenReturn(Path.of("/tmp/predictable"));
    // Upload a good image first
    mockMvc.perform(multipart("/api/upload").file(goodFile))
           .andExpect(status().isOk());
    // Quickly replace the temp file with a symlink to a sensitive location
    Files.createSymbolicLink(Paths.get("/tmp/predictable"), Paths.get("/etc/shadow"));
    // Upload another file; the move should fail or be blocked
    mockMvc.perform(multipart("/api/upload").file(anotherFile))
           .andExpect(status().isInternalServerError())
           .andExpect(result -> assertTrue(result.getResponse().getContentAsString()
                   .contains("Invalid file")));
}

Fix & prevention

7. Bug Pattern 6: Incorrect MIME Type / Content‑Sniffing Bypass

Why it happens

Some applications decide how to treat an uploaded file based on the Content-Type header supplied by the client, or they rely on browser content‑sniffing to decide whether to render the file as an image or execute it as script. If the server later serves the file with a Content-Type: text/html (or omits the header), browsers may sniff the content and execute embedded JavaScript, leading to XSS.

User impact

Reproduce & detect

Manual

Upload a file that contains a valid JPEG header followed by a script:


printf "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00" > polyglot.jpg
echo "<script>alert('xss')</script>" >> polyglot.jpg
curl -F "file=@polyglot.jpg" https://example.com/api/upload

Then request the uploaded URL and view the response headers; if Content-Type is text/html or missing, the browser may execute the script.

Automated


import requests
url = "https://example.com/api/upload"
files = {"file": ("polyglot.jpg", open("polyglot.jpg","rb"), "image/jpeg")}
r = requests.post(url, files=files)
file_url = r.json()["url"]
resp = requests.get(file_url)
# Expect image/jpeg and no script tags in body
assert resp.headers["Content-Type"].startswith("image/")
assert "<script>" not in resp.text

Fix & prevention

8. Bug Pattern 7: Missing Accessibility / Alt Text Handling

Why it happens

Developers often treat image uploads as a binary blob and forget that the image must be perceivable by users who rely on screen readers. If the UI does not prompt for or store alternative text, or if the alt attribute is left empty, the image becomes inaccessible. In some cases, the server may discard any accompanying alt field sent by the client, assuming it is unnecessary.

User impact

Reproduce & detect

Manual

Upload an image via the UI, then inspect the resulting HTML (e.g., using Chrome DevTools → Elements). Verify whether an alt attribute is present and meaningful. If the field is missing or contains only the filename, the bug exists.

Automated (using axe‑core or pa11y):


import {axe} from 'axe-core';
describe('Image upload accessibility', () => {
  it('should have meaningful alt text', async () => {
    await page.goto('https://example.com/gallery');
    const results = await axe.run(page);
    expect(results.violations).toEqual([]); // axe will flag missing alt
  });
});

You can also unit‑test the backend: assert that the stored metadata includes an alt field when supplied, and that the API returns a 400 error if the field is blank when required.

Fix & prevention

9. Bug Pattern 8: CDN / Cache Invalidation Issues After Upload

Why it happens

Many apps upload images to an origin storage (e.g., S3) and rely on a CDN (CloudFront, Akamai, Fastly) to cache copies for performance. When a user replaces an avatar or updates a product photo, the CDN may continue serving the stale cached version if the cache key (usually the URL) does not change and no purge/invalidation is triggered.

User impact

Reproduce & detect

Manual

  1. Upload an image avatar.jpg and note the URL (e.g., https://cdn.example.com/avatars/123.jpg).
  2. Replace the image with a new version using the same filename (same URL).
  3. Wait a few minutes (or force a CDN edge refresh via the provider’s console) and reload the URL. If the old image persists, the invalidation failed.

Automated (using a CDN API or a test that checks response headers):


import requests, time
cdn_url = "https://cdn.example.com/avatars/123.jpg"
# Step 1: upload first version
requests.post("https://example.com/api/upload", files={"file": ("avatar.jpg", open("v1.jpg","rb"), "image/jpeg")})
assert requests.get(cdn_url).headers.get("ETag") == "etag-v1"
# Step 2: upload second version
requests.post("https://example.com/api/upload", files={"file": ("avatar.jpg", open("v2.jpg","rb"), "image/jpeg")})
# Immediately check; expect new ETag if invalidation works
new_etag = requests.get(cdn_url).headers.get("ETag")
assert new_etag != "etag-v1", "CDN still serving old version"
# Optionally wait for TTL expiry and re‑check
time.sleep(60)
assert requests.get(cdn_url).headers.get("ETag") == "etag-v2"

Fix & prevention

10. Bug Pattern 9: Concurrent Upload Throttling / DoS via Many Small Files

Why it happens

Rate‑limiting is often applied per‑IP or per‑user on the request size (e.g., max 10 MB per request). An attacker can bypass this by sending thousands of tiny files (1 KB each) in rapid succession, consuming inodes, filling temporary directories, or exhausting concurrent‑request limits on the application server.

User impact

Reproduce & detect

Manual

Using a simple bash loop:


for i in {1..5000}; do
  curl -F "file=@/dev/zero" -H "Content-Type: application/octet-stream" \
       https://example.com/api/upload &
done
wait

Watch server logs for errors like “Too many open files” or HTTP 503 responses.

Automated (using a concurrency library like locust or k6):


import http from 'k6/http';
export let options = {
  vus: 100,          // 100 virtual users
  duration: '30s',
};
export default function () {
  const file = http.file(open('/dev/zero', 'b'), 'tiny.bin', 'application/octet-stream');
  const res = http.post('https://example.com/api/upload', { file: file });
  // Expect either 200 (if within limit) or 429 (rate limited) – never 500
  if (res.status === 500) {
    fail('Server error under concurrent small uploads');
  }
}

Fix & prevention

11. Bug Pattern 10: Image Processing Library Vulnerabilities (e.g., ImageMagick, Pillow)

Why it happens

Many services delegate thumbnail generation, format conversion, or metadata stripping to external libraries. These libraries have a history of CVEs (e.g., ImageMagick’s “ImageTragick”, Pillow’s DOS via malformed BMP). If you run the library with the same privileges as your application and feed it unsanitized byte streams, an attacker can trigger remote code execution, memory exhaustion, or infinite loops.

User impact

Reproduce & detect

Manual (ImageMagick example):


# Create a malicious MVG file that invokes a shell command
echo 'push graphic-context
viewbox 0 0 640 480
fill "url(https://example.com/image.jpg");'
pop graphic-context' > exploit.mvg
# Convert it – if vulnerable, it will run the command
convert exploit.mvg out.png

If out.png contains the result of the command (e.g., a file written to /tmp), the library is vulnerable.

Automated (using a test container):


import subprocess, tempfile, os
def run_imagemagick(data):
    with tempfile.NamedTemporaryFile(suffix='.mvg', delete=False) as f:
        f.write(data)
        fname = f.name
    try:
        out = subprocess.check_output(['convert', fname, 'png'], stderr=subprocess.STDOUT)
        return out
    finally:
        os.unlink(fname)

# Payload that attempts to write a file
payload = b'''push graphic-context
viewbox 0 0 480 480
fill 'url

Test Your App Autonomously

Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.

Try SUSA Free