Audit Penuh

Proyek audit penuh atas aplikasi yang sengaja dibuat rentan, dari ujung ke ujung.

Daftar isi

Topik ini mengerjakan proyek audit keamanan dari ujung ke ujung terhadap sebuah aplikasi lab yang sengaja dibuat rentan bernama Catatan Pegawai. Aplikasi lab dibangun terlebih dahulu menggunakan pustaka bawaan Python, empat pemeriksaan keamanan disusun menjadi skrip yang dapat diulang, lalu setiap dugaan kelemahan dibuktikan dengan pasangan permintaan dan respons. Hasil akhirnya berupa laporan empat temuan yang telah dipetakan ke kategori OWASP Top 10:2025, siap untuk dinilai risikonya pada tahap penyusunan peringkat.

Lingkup dan tahapan audit#

Audit keamanan adalah kegiatan sistematis untuk menemukan, membuktikan, dan mendokumentasikan kelemahan sebuah aplikasi. Lingkup proyek ini sengaja dibatasi pada satu aplikasi lab kecil yang berjalan di localhost, yaitu aplikasi internal pencatatan tugas dan pesan antarpegawai. Pembatasan tersebut bukan sekadar kesopanan, melainkan syarat etika dan hukum karena pengujian terhadap sistem milik pihak lain memerlukan izin tertulis. Dengan lingkup yang kecil, seluruh perhatian dapat diarahkan pada kualitas bukti, bukan pada kerumitan administrasi.

Metodologinya terdiri atas empat tahap yang berulang, sebagaimana dipetakan pada diagram berikut. Diagram menunjukkan alur dari pemetaan endpoint, pemeriksaan tiap endpoint, pengumpulan bukti, hingga pencatatan temuan beserta kategorinya. Siklus itu berulang karena temuan pada satu endpoint sering membuka dugaan baru pada endpoint lain.

Empat tahap audit keamanan aplikasi webPetaendpoint dan alurPeriksauji tiap endpointBuktikanpermintaan-responsCatattemuan + kategoritemuan mengarahkan endpoint yang diperiksa berikutnya
Empat tahap audit keamanan yang berulang

Tahap pemetaan menghasilkan daftar endpoint, yaitu titik masuk aplikasi yang dapat dihubungi melalui HTTP. Aplikasi ini memiliki lima endpoint, yaitu POST /masuk untuk sesi masuk, GET / untuk beranda, GET /catatan/<id> untuk membaca satu catatan, GET /cari?q= untuk pencarian, dan GET /reset untuk token pengaturan ulang kata sandi. Setiap endpoint kemudian diperiksa satu per satu dengan pola yang sama, yaitu mengubah masukan, mengamati respons, lalu menyimpan bukti bila responsnya tidak sesuai harapan. Daftar periksa OWASP WSTG (Web Security Testing Guide) memandu urutan pemeriksaan ini sehingga tidak ada area yang terlewat hanya karena tidak terlintas.

Aplikasi lab Catatan Pegawai#

Aplikasi lab ini menyimpan empat catatan milik dua pengguna uji, budi dan citra, beserta sesi masuk sederhana berbasis cookie, yaitu data kecil yang dikirim browser kembali pada setiap permintaan. Fungsinya tampak sehat: pengguna dapat masuk, membaca daftar catatannya sendiri, mencari pesan, dan meminta token pengaturan ulang kata sandi. Empat kelemahan ditanam secara sengaja di dalam kode, masing-masing ditandai komentar yang diawali kata “Temuan” sehingga mudah ditelusuri kembali setelah audit selesai. Simpan berkas berikut sebagai app-catatan.py, lalu jalankan sebagai server dengan python3 app-catatan.py bila ingin menelusurinya melalui browser.

python app-catatan.py
# tidak-dijalankan
"""Aplikasi lab "Catatan Pegawai" yang sengaja dibuat rentan untuk audit.

Aplikasi internal ini mencatat tugas dan pesan antarpegawai. Empat kelemahan
ditanam secara sengaja sebagai bahan latihan audit: IDOR pada /catatan/<id>,
reflected XSS pada /cari, token reset yang dapat diprediksi pada /reset, dan
pesan error debug yang membocorkan traceback. Jalankan sebagai server dengan
`python3 app-catatan.py`, lalu buka http://127.0.0.1:8801/.
"""

import traceback
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse

CATATAN = {
    "1": {"pemilik": "budi", "judul": "Tugas minggu ini",
          "isi": "Menyiapkan laporan pemeliharaan server bulan Maret."},
    "2": {"pemilik": "budi", "judul": "Pesan untuk tim",
          "isi": "Rapat koordinasi dipindah dari Rabu ke Kamis."},
    "3": {"pemilik": "citra", "judul": "Evaluasi pengadaan",
          "isi": "Daftar penawaran kertas sedang ditinjau ulang."},
    "4": {"pemilik": "citra", "judul": "Catatan pribadi",
          "isi": "Kata sandi sementara akun uji internal: lab-2026."},
}

SESI = {"sesi-budi": "budi", "sesi-citra": "citra"}

# Temuan 3: token reset dibangkitkan dari penghitung global berurutan.
NOMOR_RESET = 0


def buat_token_reset():
    """Mengembalikan token reset yang polanya dapat ditebak."""
    global NOMOR_RESET
    NOMOR_RESET += 1
    return f"rst-{NOMOR_RESET:06d}"


def halaman(judul, tubuh):
    return f"<!DOCTYPE html><html><head><title>{judul}</title></head><body>{tubuh}</body></html>"


class PenanganCatatan(BaseHTTPRequestHandler):
    """Handler HTTP dengan keempat kelemahan di atas."""

    protocol_version = "HTTP/1.1"

    def _kirim(self, kode, isi, jenis="text/html; charset=utf-8", cookie=None):
        data = isi.encode("utf-8")
        self.send_response(kode)
        self.send_header("Content-Type", jenis)
        self.send_header("Content-Length", str(len(data)))
        if cookie:
            self.send_header("Set-Cookie", cookie)
        self.end_headers()
        self.wfile.write(data)

    def _pengguna(self):
        kue = self.headers.get("Cookie", "")
        for pasangan in kue.split(";"):
            if "=" in pasangan:
                nama, nilai = pasangan.strip().split("=", 1)
                if nama == "sesi":
                    return SESI.get(nilai)
        return None

    def do_POST(self):
        if urlparse(self.path).path == "/masuk":
            panjang = int(self.headers.get("Content-Length") or 0)
            isi = self.rfile.read(panjang).decode("utf-8")
            nilai = parse_qs(isi).get("pengguna", [""])
            if nilai and nilai[0] in ("budi", "citra"):
                pengguna = nilai[0]
                self._kirim(200, f"masuk sebagai {pengguna}", "text/plain; charset=utf-8",
                            cookie=f"sesi=sesi-{pengguna}; Path=/")
            else:
                self._kirim(401, "pengguna tidak dikenal", "text/plain; charset=utf-8")
        else:
            self._kirim(404, "endpoint tidak ada", "text/plain; charset=utf-8")

    def do_GET(self):
        try:
            self._rute()
        except Exception:
            # Temuan 4: traceback dikirim apa adanya ke klien.
            self._kirim(500, halaman("Error", "<pre>" + traceback.format_exc() + "</pre>"))

    def _rute(self):
        url = urlparse(self.path)
        if url.path == "/":
            pengguna = self._pengguna()
            if pengguna is None:
                self._kirim(401, halaman("Masuk", "<p>Anda belum masuk.</p>"))
                return
            daftar = "".join(
                f"<li>/catatan/{id} - {catatan['judul']}</li>"
                for id, catatan in CATATAN.items()
                if catatan["pemilik"] == pengguna
            )
            self._kirim(200, halaman("Beranda", f"<h1>Catatan {pengguna}</h1><ul>{daftar}</ul>"))
        elif url.path.startswith("/catatan/"):
            # Temuan 1: catatan diambil hanya dari id, tanpa pemeriksaan pemilik.
            id_catatan = str(int(url.path.rsplit("/", 1)[1]))
            catatan = CATATAN[id_catatan]
            tubuh = f"<h1>{catatan['judul']}</h1><p>{catatan['isi']}</p>"
            self._kirim(200, halaman("Catatan", tubuh))
        elif url.path == "/cari":
            # Temuan 2: parameter q dipantulkan tanpa pengodean HTML.
            q = parse_qs(url.query).get("q", [""])[0]
            tubuh = f"<h1>Pencarian</h1><p>Hasil untuk: {q}</p>"
            self._kirim(200, halaman("Cari", tubuh))
        elif url.path == "/reset":
            pengguna = self._pengguna()
            if pengguna is None:
                self._kirim(401, halaman("Masuk", "<p>Anda belum masuk.</p>"))
                return
            token = buat_token_reset()
            tubuh = f"<p>Token reset untuk {pengguna}: {token}</p>"
            self._kirim(200, halaman("Reset", tubuh))
        else:
            self._kirim(404, halaman("Tidak ditemukan", "<p>Halaman tidak ada.</p>"))

    def log_message(self, format, *args):
        pass  # log akses tidak dicetak agar keluaran audit tetap bersih


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8801), PenanganCatatan).serve_forever()

Pada server sungguhan, keempat kelemahan itu tidak akan tertulis dalam komentar. Karena itu, kebiasaan yang dilatih pada proyek ini bukan mencari tanda komentar, melainkan menguji perilaku tiap endpoint: meminta objek milik pengguna lain, mengirim masukan berbahaya, membandingkan token yang dibangkitkan berurutan, serta memicu error. Pemeriksaan semacam ini dapat dikerjakan secara manual dengan curl atau proxy, tetapi otomatisasi membuatnya dapat diulang kapan saja dengan bukti yang konsisten.

Empat pemeriksaan otomatis#

Skrip audit di bawah ini memuat aplikasi lab, menjalankannya sebagai thread, yaitu alur kerja terpisah di dalam proses yang sama, pada 127.0.0.1:8801, lalu menjalankan empat pemeriksaan yang meniru langkah penyerang. Pemeriksaan IDOR (Insecure Direct Object References) membaca catatan 3 milik citra dengan sesi budi. Pemeriksaan XSS (Cross-Site Scripting) mengirim payload, yaitu masukan berbahaya yang disiapkan penyerang, ke pencarian dan memeriksa apakah pantulannya muncul mentah dalam respons, ciri varian reflected XSS. Pemeriksaan token meminta dua token reset berturut-turut dan membandingkan polanya. Pemeriksaan error mengirim id non-angka dan memeriksa apakah traceback, yaitu catatan rantai pemanggilan saat error terjadi, ikut terkirim ke klien. Simpan berkasnya sebagai audit-otomatis.py, lalu jalankan dengan python3 audit-otomatis.py dari folder yang sama dengan app-catatan.py.

python audit-otomatis.py
"""Audit otomatis atas aplikasi lab Catatan Pegawai.

Skrip ini memuat app-catatan.py, menjalankannya sebagai thread di
127.0.0.1:8801, melakukan empat pemeriksaan yang sama dengan penelusuran
manual, lalu mencetak daftar temuannya. Jalankan dengan
`python3 audit-otomatis.py` dari folder tempat app-catatan.py berada.
"""

import importlib.util
import re
import threading
import urllib.error
import urllib.parse
import urllib.request
from http.server import ThreadingHTTPServer

BERKAS_APLIKASI = "app-catatan.py"
DASAR = "http://127.0.0.1:8801"
PAYLOAD_XSS = "<script>alert(1)</script>"


def muat_aplikasi():
    """Memuat app-catatan.py dari folder kerja tanpa menjalankan servernya."""
    spesifikasi = importlib.util.spec_from_file_location("app_catatan", BERKAS_APLIKASI)
    modul = importlib.util.module_from_spec(spesifikasi)
    spesifikasi.loader.exec_module(modul)
    return modul


def minta(jalur, data=None, cookie=None):
    """Mengirim satu permintaan HTTP dan mengembalikan (status, isi, header)."""
    metode = "POST" if data is not None else "GET"
    permintaan = urllib.request.Request(DASAR + jalur, data=data, method=metode)
    if cookie:
        permintaan.add_header("Cookie", cookie)
    try:
        with urllib.request.urlopen(permintaan, timeout=5) as jawaban:
            return jawaban.status, jawaban.read().decode("utf-8"), jawaban.headers
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8"), e.headers


def masuk(pengguna):
    """Login sebagai pengguna lab dan mengembalikan nilai cookie sesinya."""
    data = urllib.parse.urlencode({"pengguna": pengguna}).encode("utf-8")
    _, _, header = minta("/masuk", data=data)
    return header.get("Set-Cookie", "").split(";", 1)[0]


def periksa_idor():
    """Budi mencoba membaca catatan 3 milik citra."""
    kuki = masuk("budi")
    status, isi, _ = minta("/catatan/3", cookie=kuki)
    if status == 200 and "Evaluasi pengadaan" in isi:
        return "TEMUAN (A01) — budi membaca catatan 3 milik citra -> 200"
    return None


def periksa_xss():
    """Payload script dikirim ke pencarian, lalu pantulannya diperiksa."""
    q = urllib.parse.quote(PAYLOAD_XSS)
    status, isi, _ = minta(f"/cari?q={q}")
    if status == 200 and PAYLOAD_XSS in isi:
        return "TEMUAN (A05) — payload dipantulkan tanpa pengodean di /cari"
    return None


def periksa_token():
    """Dua token reset diminta berturut-turut, lalu polanya dibandingkan."""
    kuki = masuk("budi")
    token = []
    for _ in range(2):
        _, isi, _ = minta("/reset", cookie=kuki)
        cari = re.search(r"rst-\d{6}", isi)
        if cari:
            token.append(cari.group(0))
    nomor = [int(t.split("-")[1]) for t in token]
    if len(nomor) == 2 and nomor[1] == nomor[0] + 1:
        return f"TEMUAN (A07) — token {token[0]} lalu {token[1]}, pola berurutan"
    return None


def periksa_error():
    """Id non-angka dipicu, lalu responsnya diperiksa dari traceback."""
    kuki = masuk("budi")
    status, isi, _ = minta("/catatan/abc", cookie=kuki)
    if status == 500 and "Traceback (most recent call last)" in isi:
        return "TEMUAN (A10) — traceback ValueError dikirim utuh ke klien"
    return None


def main():
    modul = muat_aplikasi()
    server = ThreadingHTTPServer(("127.0.0.1", 8801), modul.PenanganCatatan)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    try:
        pemeriksaan = (
            ("IDOR pada catatan", periksa_idor),
            ("Reflected XSS", periksa_xss),
            ("Token reset", periksa_token),
            ("Error debug", periksa_error),
        )
        print("== Audit lab Catatan Pegawai ==")
        jumlah = 0
        for nama, fungsi in pemeriksaan:
            hasil = fungsi()
            if hasil is None:
                print(f"{nama:<18} : tidak ditemukan")
            else:
                jumlah += 1
                print(f"{nama:<18} : {hasil}")
        print(f"Total: {jumlah} temuan dari {len(pemeriksaan)} pemeriksaan")
    finally:
        server.shutdown()
        server.server_close()


if __name__ == "__main__":
    main()
text Keluaran
== Audit lab Catatan Pegawai ==
IDOR pada catatan  : TEMUAN (A01) — budi membaca catatan 3 milik citra -> 200
Reflected XSS      : TEMUAN (A05) — payload dipantulkan tanpa pengodean di /cari
Token reset        : TEMUAN (A07) — token rst-000001 lalu rst-000002, pola berurutan
Error debug        : TEMUAN (A10) — traceback ValueError dikirim utuh ke klien
Total: 4 temuan dari 4 pemeriksaan

Keempat baris keluaran mengonfirmasi keempat dugaan dengan bukti yang ringkas. Baris IDOR memperlihatkan kode 200 untuk objek milik pengguna lain, yang pada OWASP Top 10:2025 termasuk kategori A01 Broken Access Control. Baris XSS menunjukkan payload yang dipantulkan tanpa pengodean dan masuk kategori A05 Injection. Baris token memperlihatkan dua nilai berurutan rst-000001 dan rst-000002 sehingga nilainya dapat ditebak, yang termasuk kategori A07 Authentication Failures. Baris error membuktikan traceback terkirim utuh ke klien, yang termasuk kategori A10 Mishandling of Exceptional Conditions.

Kesalahan umum#

Kesalahan berikut paling sering muncul ketika proyek audit kecil dikerjakan pertama kali.

  • Mengaudit sekaligus memperbaiki. Kode yang berubah di tengah audit mengacaukan bukti, sehingga perbaikan sebaiknya dimulai setelah daftar temuan ditutup.
  • Menyimpulkan tanpa bukti. Klaim “sepertinya rentan” tanpa pasangan permintaan dan respons sulit dinilai orang lain, sehingga setiap temuan wajib menyertakan cara memicunya kembali.
  • Menguji hanya melalui antarmuka. Tautan yang disembunyikan tetap dapat dipanggil langsung, sehingga pemeriksaan perlu menyasar permintaan HTTP-nya.
  • Melaporkan gejala, bukan penyebab. Pesan “respons berisi teks aneh” kurang berguna dibandingkan “parameter q dipantulkan tanpa pengodean HTML”.

Ringkasan#

  • Audit berjalan dalam empat tahap yang berulang, yaitu memetakan endpoint, memeriksa, membuktikan, dan mencatat temuan.
  • Aplikasi lab Catatan Pegawai memuat empat kelemahan yang terbukti melalui skrip audit otomatis: IDOR, reflected XSS, token reset berurutan, dan traceback yang bocor ke respons.
  • Keempat temuan dipetakan ke kategori OWASP Top 10:2025, yaitu A01, A05, A07, dan A10, sebagai persiapan penilaian risiko.

Referensi#