synology-improbability-mr-me

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.