The Synology Improbability

Recently, my manager purchased a Synology NAS device for me to do some backups. Since quite a few people I know use this particular NAS (including myself now), I decided to do a quick audit on it before integrating it into my lab environment. In this blog post, I will cover two different vulnerabilities patched by Synology.

Upon initial inspection, I saw that one of the default applications that was installed (or at least prompted to install during the setup) was the Photo Station. This is a pure PHP target so I decided to have a look at it. My scope was limited to this application as this was a “Sunday morning with a cup of tea” kind of thing.

Synology Photo Station LogList Stored Cross Site Scripting Authentication Bypass Vulnerability

This vulnerability is triggered when making API requests to the /volume1/@appstore/PhotoStation/photo/webapi/log.php script. An admin can do this when they are logged into the NAS.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class PhotoLogAPI extends WebAPI {
private $operationPemission = "admin";

function __construct() {
parent::__construct(SZ_WEBAPI_API_DESCRIPTION_PATH);
}

protected function Process()
{
if (!strcasecmp($this->method, "list")) { // 1. Test for "list"
$this->LogList(); // 2. call LogList
} else if (!strcasecmp($this->method, "clear")) {
$this->Clear();
} else if (!strcasecmp($this->method, "export")) {
$this->Export();
}
}

private function LogList()
{
$resp = array();

$params = $this->GetParams_List();
if (!$params) {
$this->SetError(WEBAPI_ERR_BAD_REQUEST);
goto End;
}

$res = PhotoLog::ListItem($params['offset'], $params['limit']); // 3. Pull data from the database

$resp['items'] = $res['data'];
$resp['total'] = $res['total'];
$offset = (0 > (int)$params['limit']) ? $resp['total'] : $params['offset'] + $params['limit'];
$resp['offset'] = ($offset > $resp['total']) ? $resp['total'] : $offset;

$this->SetResponse($resp); // 6. Set the response, no validation
End:
return;
}

This code is called as an AJAX request when an admin clicks on “Log” inside of the admin interface. At [1], the code checks for the method list and if it exists, calls LogList at [2]. Then at [3], the code calls the static function ListItem from the PhotoLog class defined in /volume1/@appstore/PhotoStation/photo/include/log.php.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static function ListItem ($offset, $limit)
{
$query = "SELECT * FROM photo_log ORDER BY create_time DESC, logid DESC LIMIT ? OFFSET ?";
$dbResult = PHOTO_DB_Query($query, array($limit, $offset)); // 4. execute prepared query

$data = array();
while(($row = PHOTO_DB_FetchRow($dbResult))) {
$item = array(
'success' => PHOTO_DB_IsTrue($row['success']),
'create_time' => $row['create_time'],
'logid' => $row['logid'],
'log' => $row['log'],
'user' => $row['user'] // 5. get the attempted logged in user
);
$data[] = $item;
}

$dbResult = PHOTO_DB_Query("SELECT count(*) FROM photo_log");
$row = PHOTO_DB_FetchRow($dbResult);

return array('data' => $data,
'total' => $row[0]);
}

The code here pulls the data directly out of the database. Most importantly, the user is used at [5] and then it is returned as an array back to the caller. Finally, at [6], the data is returned without any HTML escaping, allowing for a cross-site scripting vulnerability to occur.

An unauthenticated attacker can trigger this by attempting to log in to the application.

1
2
3
4
5
6
7
POST /photo/webapi/auth.php HTTP/1.1
Host:
Content-Length: 127
Connection: close
Content-Type: application/x-www-form-urlencoded

username="><img+src=a+onerror=alert(1)+/>&version=1&enable_syno_token=true&api=SYNO.PhotoStation.Auth&password=&method=login

Note the exclusion of a password and sessionid. Now, when an admin views the logs, the inline JavaScript should be fired. However, Synology implements a Content Security Policy (CSP). The following domains are within scope and I feel like I could probably find some content reflection on *.synology.com, but that is out of scope for me and really is of no interest.

  • https://*.synology.com
  • https://*.google.com
  • https://*.googleapis.com
  • https://dme0ih8comzn4.cloudfront.net/js/feather.js
  • feather.aviary.com http://featherservices.aviary.com
  • *.google-analytics.com https://dme0ih8comzn4.cloudfront.net

However, it turns out that the latest version of MSIE 11 executes inline JavaScript despite the CSP implementation–thanks Microsoft! We will cover a similar vulnerability in our live AWAE class at Black Hat Asia 2018.

Synology Photo Station SYNOPHOTO_Flickr_MultiUpload Race Condition File Write Remote Code Execution Vulnerability

Once we have bypassed authentication, the next thing to do is (ab)use something. Synology has done a decent job of securing their PhotoStation application over the years from others discovering SQL Injection and other critical vulnerabilities to bypass authentication.

What I found interesting is that it seems like nobody had abused the PHP to execute code. The surface wasn’t big, but it was interesting!

We begin our journey in /volume1/@appstore/PhotoStation/photo/SocialNetwork/flickr.php, revealing the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
} else if (isset($_POST['action'])) {
switch ($_POST['action']) {
case "upload":
...
case "multi_upload":
if (!isset($_POST['prog_id']) || !isset($_POST['token']) ||
!isset($_POST['secret']) || !isset($_POST['photoList']) || strpos($_POST['grog_id'], "..") !== FALSE) { // 1. checks for some post parameters
$result['message'] = "invalid multi_upload arguments";
echo json_encode($result);
exit();
}
csSYNOPhotoDB::GetDBInstance()->SetSessionCache(true);
session_write_close();
SYNOPHOTO_Flickr_MultiUpload($_POST['prog_id'], $_POST['token'], $_POST['secret'], $_POST['photoList']); // 2. calls SYNOPHOTO_Flickr_MultiUpload with constrolled data
break;

At [1], there is a check for a few POST variables and a check for “..” in grog_id. That’s important, as we’ll see later. Then, if we pass this check, we call into SYNOPHOTO_Flickr_MultiUpload at [2] with controlled POST parameters. Note that prog_id is used here.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function SYNOPHOTO_Flickr_MultiUpload($progressId, $oauthToken, $tokenSecret, $photoList)
{
if (!csSYNOPhotoMisc::IsAllowedSocialUploadIdentity()) {
$result = array("success" => false, "message" => "not allowed identity");
echo json_encode($result);
exit;
}

$photoIds = explode(',', $photoList); // 3. creates photoIds from user controlled input
if (!@file_exists(FLICKR_UPLOAD_TMP)) {
@mkdir(FLICKR_UPLOAD_TMP, 0777, true);
} else if (!is_dir(FLICKR_UPLOAD_TMP)) {
$result = array("success" => "finish", "error" => "failed to create tmpDir");
@file_put_contents(FLICKR_UPLOAD_TMP."/".$progressId, json_encode($progress)); // 4. create an arbitrary file here
exit();
}
$totalNum = count($photoIds);
$uploadedNum = 0;
foreach ($photoIds as $id) { // 5. loops through user controlled data
if (@file_exists(FLICKR_UPLOAD_TMP."/".$progressId."_cancel")) {
break;
}
$result = getPhotoPath($id); // 6. calls getPhotoPath with controlled $id
if (!$result['success']) { // 16. make sure it returns a valid result
continue;
}
$realPath = $result['path'];
$filename = substr($realPath, strrpos($realPath, '/')+1);
$progress = array(
"name" => $result['filename'], // 17. $progress contains php code
"percent" => number_format($uploadedNum/$totalNum, 2)
);
// write to progress file
@file_put_contents(FLICKR_UPLOAD_TMP."/".$progressId, json_encode($progress)); // 18. file write vulnerability - race starts here
$result = uploadPhoto($tokenSecret, $oauthToken, $realPath, $result['filename'], "");
if (!$result['success']) {
echo $filename." ".$result['message'];
}
OutputSingleSpace();
$uploadedNum++;
}
$progress = array("success" => "finish");
@file_put_contents(FLICKR_UPLOAD_TMP."/".$progressId, json_encode($progress)); // 19. race condition window closed here
@unlink(FLICKR_UPLOAD_TMP."/".$progressId."_cancel");
echo json_encode($progress);
}

At [3] the code creates an array from user-controlled input, then at [4] the code creates a file that is partially controlled by the attacker. This is not exploitable since newer versions of PHP don’t allow for NULL-byte injection.

Then, at [6] we can see a call to getPhotoPath:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
function getPhotoPath($imageId)
{
$arr = explode('_', $imageId); // 7. make sure our imageId has 3 _ in it
if (3 !== count($arr)) {
$result = array("success" => false, "message" => "invalid id format");
return $result;
}
if ($arr[0] !== "photo") { // 8. make sure photo is the first value
$result = array("success" => false, "message" => "not photo id");
return $result;
}
$dirName = @pack('H*', $arr[1]); // 9. unpack directory from controlled data
$fileName = @pack('H*', $arr[2]); // 10. unpack filename from controlled data
if ('/' === $dirName) {
$filePath = $fileName;
} else {
$filePath = $dirName.'/'.$fileName;
}
$path = realpath(SYNOPHOTO_SERVICE_REAL_DIR.'/'.$filePath);

$useOrig = csSYNOPhotoMisc::GetConfigDB("photo", "share_upload_orig", "photo_config") == 'on'; // 11. check share_upload_orig setting is enabled
$photoInfo = csSYNOPhotoDB::GetDBInstance()->GetPhotoInfo($path); // 12. needs to be a valid photo

$realPath = $path;
//30MB is limit
if(!$useOrig || !isset($photoInfo['size']) || 30*1024*1024 false, "message" => "file not found");
return $result;
}

//check access right // 14. need to be admin
if (strpos($realPath, realpath(SYNOPHOTO_SERVICE_REAL_DIR)) !== 0) {
$result = array("success" => false, "message" => "access denied");
return $result;
}
if (!CheckAlbumAccessRight($dirName)) {
$result = array("success" => false, "message" => "access denied");
return $result;
}
if (!isset($_SESSION[SYNOPHOTO_ADMIN_USER]['admin_syno_user']) && !csSYNOPhotoMisc::GetConfigDB("photo", "allow_guest_fb_upload", "photo_config")) {
$result = array("success" => false, "message" => "no upload permission");
return $result;
// check flickr upload Setting
$social = SocialNetwork::ReadList();
if (is_array($social)) {
foreach ($social as $item) {
if ('Flickr' === $item['name'] && true !== $item['enable']) {
$result = array("success" => false, "message" => "no youtube upload permission");
return $result;
}
}
}
$result['success'] = true;
$result['path'] = $realPath;
$result['filename'] = $fileName; // 15. set the filename
return $result;
}

This function checks that the id variable can be broken into a valid path and performs a few checks on the path, such as:

  • The file a valid image [12]
  • The file does exist [13]
  • The current user is admin [14]
  • etc

Then at [15], the array is set with our controlled filename. However, since we control the filename and we are attacking a Linux system, we can write PHP code into the filename itself and store it. This is so at [17] we have PHP code in our array. Then at [18], the file write vulnerability occurs and the window is opened up between [18] and [19]. At [19], the PHP file is overwritten with data we do not control, so the race is finished.

It’s a small window, but its easily won :->

Patch / Mitigation

The patch I wrote is literally a single byte change in flickr.php. I suspect that the developer made a typo and didn’t test the code properly. They were trying to filter grog_id which is never used, its actually prog_id!

1
2
3
4
5
6
7
8
9
10
11
12
saturn:synology mr_me$ diff -u photo/SocialNetwork/flickr.php photo/SocialNetwork/flickr.php.new
--- photo/SocialNetwork/flickr.php 2017-11-22 05:13:58.000000000 -0600
+++ photo/SocialNetwork/flickr.php.new 2017-12-19 11:12:48.000000000 -0600
@@ -38,7 +38,7 @@
break;
case "multi_upload":
if (!isset($_POST['prog_id']) || !isset($_POST['token']) ||
- !isset($_POST['secret']) || !isset($_POST['photoList']) || strpos($_POST['grog_id'], "..") !== FALSE) {
+ !isset($_POST['secret']) || !isset($_POST['photoList']) || strpos($_POST['prog_id'], "..") !== FALSE) {
$result['message'] = "invalid multi_upload arguments";
echo json_encode($result);
exit();
Timeline
  1. Verified and sent to Synology: 19 Dec 17
  2. Coordinated public release of advisory: 14 Jan 18
Conclusion

It can be very expensive maintaining PHP code and even if it has been looked over a lot previously, critical vulnerabilities can still surface. PHP race conditions are quite rare and as such, we cover similar vulnerabilities to the one discussed here in our AWAE class. If this is the kind of web security you enjoy, stay tuned!

As a side note, it’s a little be disheartening to see that Synology downplayed the vulnerabilities described above, however, it was nice to see a responsive turnaround time. You can also download the exploit.


Menu
X Close

 

Certified Pentesting
Professional

OSCP
course starting at
$800 USD

Take Penetration Testing with Kali Linux to gain invaluable penetration testing skills and earn your OSCP.

  • Self-paced, online course
  • Includes certification exam fee
  • Access innovative virtual labs
  • Hands-on experience
  • Become an OSCP

Certified Pentesting
Expert

OSCE
course starting at
$1200 USD

Take Cracking the Perimeter to take your penetration testing skills to expert levels and earn your OSCE.

  • Self-paced, online course
  • Includes certification exam fee
  • Access innovative virtual labs
  • Hands-on experience
  • Become an OSCE

 

Certified Pentesting
Web Expert

OSWE
course starting at
$1400 USD

Take Advanced Web Attacks and Exploitation, to deep dive into web apps to earn your OSWE.

  • Self-paced, online course
  • Includes certification exam fee
  • Access innovative virtual labs
  • Hands-on experience
  • Become an OSWE

Certified Pentesting
Wireless Professional

OSWP
course starting at
$450 USD

Take Offensive Security Wireless Attacks to acquire knowledge about Wi-Fi attacks and earn your OSWP.

  • Self-paced, online course
  • Includes certification exam fee
  • Access innovative virtual labs
  • Hands-on experience
  • Become an OSWP

Certified Exploitation
Expert

OSEE
course starting at
See
Live Schedule

Take Advanced Windows Exploitation to develop exploits for Windows systems and earn your OSEE.

  • Live training course
  • Includes certification exam fee
  • Maximum instructor interaction
  • Highly challenging
  • Become an OSEE