Samsung Camera to Mastodon Bridge

Giving old Samsung compact and mirrorless cameras (2009-2015) a new life

Ge0rG (@ge0rg@chaos.social)

FOSDEM 2025, Sat 16:00 (Embedded DevRoom)

Goal: Send photos on the go

Samsung SH100 camera uploading a photo using a $20 LTE stick

Agenda

  1. Samsung WiFi cameras

  2. Reverse engineering Samsung camera API services

  3. API Re-Implementation

  1. Deployment

Samsung WiFi cameras

Samsung WiFi cameras

  • WiFi-enabled Samsung cameras released from 2009 to 2015
    • ~20 models of compacts (models ending in “F”: WBxxxF, STxxxF, …)
    • ~7 Samsung NX interchangeable-lens cameras
      (NX2000, NX30, NX300, NX3000, NX mini, NX500, NX1)
    • two camcorder models (HMX-QF20/HMX-QF30)
  • Available on second hand market (30-300€)
Collection of Samsung compact cameras with WiFi

Samsung Social Network Services

DV150F photo upload menu

SNS API

  • Central API service hosted by Samsung …until 2021
  • Authentication
  • Forwarding of pictures and videos to upstream service
  • Supported subset of services depends on camera model

Possible rationale: react to upstream API changes without firmware updates

API Communication

📸 ⇄ 🛜 ⇄ ☁️

🐍

Camera connects to WiFi / hotspot

📸 ⇄ 🛜 ⇄ ☁️

🐍

Camera talks to Samsung API: login, upload

📸 ⇄ 🛜 ⇝ ☠️

🐍

Samsung API was discontinued in 2021

📸 ⇄ 🛜 ⇝ ☠️

🐍

Redirect via DNS to own implementation!

📸 ⇄ 🛜 ⇝ ☠️

❓🐍❓

But what’s the response format?

Reverse engineering Samsung camera API services

NX300 / NX500 / NX1: Tizen Linux

NX300 with xteddy

Mirrorless cameras since 2013 run a Samsung-branded Linux OS

  • OS-level root access possible
  • Runs X11, enlightenment
  • Camera UI: 5MB binary blob
    di-camera-app 🤬
  • Limited access to app logs and test commands
    • inject key presses
    • change parameters

  • Redirect DNS ➛ ??? ➛ PROFIT!

Two distinct APIs: Email

POST http://www.ospserver.net/social/columbus/email?DUID=123456789033 HTTP/1.0
Authorization:OAuth *snip*
x-osp-version:v1
User-Agent: sdeClient
Content-Type: multipart/form-data; boundary=---------------------------7d93b9550d4a
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)

-----------------------------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.\r\nlanguage_sh100_utf8]]></body></email>

-----------------------------7d93b9550d4a
content-disposition: form-data; name="binary"; fileName="SAM_4371.JPG"
content-Type: image/jpeg;
*snipped JPEG payload*
-----------------------------7d93b9550d4a
  • Unencrypted (malformed) multi-part HTTP POST with XML and image file
  • Only expects “200 OK” as response ➛ easy to implement

Two distinct APIs: Social

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

<?xml version="1.0" encoding="UTF-8"?>
<Request Method="login" Timeout="3000" CameraCryptKey="58a4c8161c8aa7b1287bc4934a2d89fa952da1cddc5b8f3d84d34
06713a7be05f67862903b8f28f54272657432036b78e695afbe604a6ed69349ced7cf46c3e4ce587e1d56d301c544bdc2d476ac5451c
eb217c2d71a2a35ce9ac1b9819e7f09475bbd493ac7700dd2e8a9a7f1ba8c601b247a70095a0b4cc3baa396eaa96648">
<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>

After some days of reverse-engineering

  • username and password are encrypted with session key
  • CameraCryptKey = RSA-encrypted session key
  • session key is generated from secureRandom()
    • rand() initialized with time()
      • 40 “random” bytes from the call stack
  • CryptSessionKey = session key encrypted with… nothing at all!

Proof-of-Concept Decryption

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from base64 import b64decode
from urllib.parse import unquote

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))
$ ./decrypt.py facebook_auth.xml
User: x Password: z
$ _

API Re-Implementation

Python Flask Service

@app.route('/social/columbus/email',methods = ['POST'])
def sendmail():
    if not 'message' in request.files:
        abort(400, "No 'message' in POST or unpatched Flask")

    xml = ET.parse(request.files['message'])
    sender = xml.find('sender').text
    name, addr = email.utils.parseaddr(sender)
    recipients = [e.text for e in xml.find('receiverList').findall('receiver')]
    title = xml.find('title').text
    body = xml.find('body').text.replace("\nlanguage_sh100_utf8", "")
    msg = Message(subject=title, sender=sender, recipients=recipients)
    msg.body = body
    for f in request.files.getlist('binary'):
        msg.attach(f.filename, f.mimetype, f.read())
    mail.send(msg)
    return make_response("Yay", 200)
  • “Unpatched Flask”: multipart ends with boundary, not with terminator
  • Flask refuses the content ➛ Patch Flask to accept invalid HTTP

Authentication & Routing

  • Plain-text HTTP, obscure API

  • Proof of concept, not meant for public deployment!

  • “Authentication” (password verification could be implemented):

      SENDERS = ['Camera@samsungcamera.com', 'georg@op-co.de']
  • Content routing (“mastodon”, “store”, “drop”):

      [ACTIONS]
      facebook = "mastodon"
      picasa = "store"
      "ge0rg@photog.social" = "mastodon"
      "store@x.com" = "store"
      "test@test.example" = "drop"

Interfacing with Mastodon

  • How many photos did the user send?
  • No place for photo alt-text!

    Solution:

  • Use "~" as separator between post content and alt-text for all pictures
    ➛ implicit number of pictures
  • User must keep track!
  • Input box has very limited length!
  • MASTODON_POSTSCRIPT for hashtags etc…

Do not enter personal information!

Hotspot: Yahoo?!

GET / HTTP/1.1
Host: www.yahoo.co.kr

HTTP/1.1 301 Moved Permanently
Location: https://www.yahoo.com/

www.yahoo.co.kr
or www.msn.com
(same mechanism)

After some more days of reverse-engineering

  • WB850F and EX2F firmare comes with partialImage.o.map
  • Firmware file contains partitions (bootloader, resources, Main_Image, …)
  • partialImage.o.map is a symbol map for Main_Image
  • Function DevHTTPResponseStart() checks Yahoo response
    • HTTP 200: must set a cookie on .yahoo.* domain
    • HTTP 30x Redirect: must contain "yahoo." at position <= 11

len("https://www.") == 12 🤬

Deployment

10€ Debian LTE stick

Ideal deployment platform:

Step-by-step instructions

Demo

Supported model list

Generation Support by re-implemented API service
DRIMeV 🤷NX1 ✅NX500
DRIMeIV ✅NX300/NX310 ✅NX2000 🤷NX30
DRIMeIII ✅NX1000/NX1100 ✅EX2F
Milbeaut ✅NX mini
WBxxxF compact 🟡WB30F/WB31F/WB32F 🟡WB35F/WB36F/WB37F 🟡WB50F ✅WB150F 🟡WB200F ✅WB250F ✅WB350F/WB351F ✅WB380F ✅WB800F ✅WB850F
bridge 🟡WB1100F ❌WB2200F
other compact ❌ST1000 ✅ST150F ✅ST200F
✅SH100 ✅DV150F 🤷DV180F ✅DV300F 🤷MV900F
camcorders 🤷HMX-S10/S15/S16 🤷HMX-QF20 ✅HMX-QF30

✅ supported | 🟡 partially | ❌ reverse engineering | 🤷 hardware donation

Summary

Backup

Camera PCB debug port

Samsung ST1000/CL65

  • First WiFi camera from Samsung, no firmware downloads available
  • API not shared with other models:
    1. GET /security/sso/initialize/time (respone in 2012 pastebin)
    2. GET /social/columbus/serviceproviders/list?DUID=*snipped*
    3. 🤷🤷🤷

ST1000 Serial Log

12:04:45.225820 # [sock]nx_tcp_client_socket_unbind [0x0]
12:04:45.228948 # [sock]nx_tcp_socket_delete [0x0]
12:04:45.231220 # [sock]Close OK [0x101]
12:04:45.233046 WebEventHandler [6]
12:04:45.236441 WebCheckRelayServer : 5 ( -7 ) 0
LifeBridgeReply(0x3c8a) 
LifeBridgeWaitMsg : done
12:04:45.245002 ui_msg_web_check_relay_server_state : 5
12:04:46.355718 WifiClose : 0(state 22)
WifiClose : 1 0 0 1 0)
12:04:46.361168 LifeBridgeWmCommand : send - 113 
LifeBridgeWmCommand : waiting reply - 113 
LifeBridgeWmCommand : end - 113 ( result : ok ) 
LifeBridgeWmCommand : send - 105 
LifeBridgeWmCommand : waiting reply - 105 
LifeBridgeWmCommand : end - 105 ( result : ok ) 
LifeBridgeWmCommand : send - 101 
[wifilib] Delete "bcm4325_assoc" thread
12:04:46.405377 [wifilib] Delete "assoc thread queue" queue

LifeBridge? nx_bcm4325_...? Dedicated WiFi SoC?
No XML dumps ➛ Need to extract firmware from camera!

HMX-QF20: It’s TLS SSL… v2!

  • HTTPS without certificate checks (SSLv2) 🥱
  • Same XML API, credentials not encrypted obfuscated
  • SSLv2 disabled at compile time in every reasonable code base
  • 🎉 socat23: 🗝 Socat with SSL v2/3 Support 🎉

Social Media Upload Sequence

What the camera does:

  1. POST /<site>/auth/ (username, password) ➛ session
  2. POST /<site>/photo/ (filename, album, message) ➛ upload id
  3. POST /upload/upload_id/file (actual picture, one POST per file)

Implications:

  • Server-side state required (session, message, uploaded files)
  • No advance knowledge of number of files (Seriously, Samsung?)

Authentication API

<Response SessionKey="{{ sessionkey }}">
<PersistKey Value="{{ persistme }}"/>
<CryptSessionKey Value="{{ csk }}"/>
<LoginPeopleID="{{ screenname }}"/>
<OAuth URL="http://snsgw.samsungmobile.com/oauth"/>
</Response>
@app.route('/<string:site>/auth',methods = ['POST'])
def auth(site):
    d = request.get_data()
    if not d:
        abort(400, 'Empty POST payload') # sometimes sent by NX300?!
    xml = ET.fromstring(d)
    method = xml.attrib["Method"] # "login" or "logout"
    if method == 'logout':
        return "Logged out for real!"
    creds = samsungxml.extract_credentials(xml) # decryption
    if not creds['user'] in app.config['SENDERS']:
        return "Login failed", 401
    session = mysession.load(None)
    session.user = creds['user']
    mysession.store(session)
    return render_template('response-login.xml',
            sessionkey=session['sid'],
            csk=session['sid'],
            screenname="Samsung NX Lover")

Weird Samsung network stack quirks

  1. Lack of multipart terminator ➛ Patch Flask

  2. TCP segmentation 😡

    ✅ Samsung (2013): one TCP segment
    ❌ Flask (2023): two TCP segments

    Patch Flask

  3. Hotspot detection 🤬

Hotspot: Certification needed

API is running on a 192.168.x.x IP address ➛ run on 10.x.x.x! (Seriously!)