Giving old Samsung compact and mirrorless cameras (2009-2015) a new life
Ge0rG (@ge0rg@chaos.social)
FrOSCon 2025 (August 16./17.)
Between 2009 and 2015, Samsung released over 30 WiFi-equipped compact and mirrorless camera models that could email photos or upload them to social media. In 2021, they shut down the required API services, crippling the WiFi functionality.
To revive the WiFi feature, the firmware of multiple camera models was reverse-engineered to understand the protocol and to “circumvent” the WiFi hotspot detection implemented by Samsung.
Based on the gained knowledge, a FOSS implementation of the API was created using the Python Flask framework, allowing to post to Mastodon right from the camera. The project supports ~75% of the camera models, looking for hardware donations and better-skilled reverse engineers to close the gap.
It was exemplarily deployed on an inexpensive LTE modem running Debian, allowing on-the-go use of the camera WiFi.
Samsung WiFi cameras
Reverse engineering Samsung camera API services
API Implementation
That Hotspot detection!!11
API Deployment
😴
January 2021: Samsung discontinues API services
May 2022: first implementation of e-mail API endpoint
November 2023: reverse-engineering of social media API
May 2024: reverse-engineering of “Yahoo” hotspot
May 2025: reverse-engineering of Samsung NX mini hotspot
Possible rationale: react to upstream API changes without firmware updates
📸 ⇄ 🛜 ⇄ ☁️
⬍
🐍
Camera connects to WiFi / hotspot
📸 ⇄ 🛜 ⇄ ☁️
⬍
🐍
Camera talks to Samsung API: login, upload
📸 ⇄ 🛜 ⇝ ☠️
⬍
🐍
Samsung API is discontinued since 2021
📸 ⇄ 🛜 ⇝ ☠️
⇅
🐍
Redirect via DNS to own implementation!
📸 ⇄ 🛜 ⇝ ☠️
⇅
❓🐍❓
But what’s the response format?
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
nx_bcm4325_...
? Dedicated WiFi SoC!Mirrorless cameras since 2013 run a
Samsung-branded
Linux OS
root
access possibledi-camera-app
🤬st
test command
# /etc/hosts
192.168.99.1 www.ospserver.net snsgw.samsungmobile.com
POST http://www.ospserver.net/social/columbus/email?DUID=123456789033 HTTP/1.0 Authorization:OAuth *snip* 👆 unencrypted HTTP request Content-Type: multipart/form-data; boundary=---------------------------7d93b9550d4a -----------------------------7d93b9550d4a content-disposition: form-data; name="message"; fileName="sample.txt" content-Type: multipart/form-data; <?xml version="1.0" encoding="UTF-8"?> 👈 XML payload with image meta-data <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* 👈 raw payload with JPEG file -----------------------------7d93b9550d4a 👈 no multipart terminator!
POST http://snsgw.samsungmobile.com/facebook/auth HTTP/1.1 👈 unencrypted HTTP request <?xml version="1.0" encoding="UTF-8"?> <Request Method="login" Timeout="3000" CameraCryptKey="58a4c8161c8aa7b1287bc4934a2d89fa952da1cddc5b8f3d84d3406713a7be05f6786290 3b8f28f54272657432036b78e695afbe604a6ed69349ced7cf46c3e4ce587e1d56d301c544bdc2d476ac5451 ceb217c2d71a2a35ce9ac1b9819e7f09475bbd493ac7700dd2e8a9a7f1ba8c601b247a70095a0b4cc3baa396 eaa96648"> 👈 1024-bit hexadecimal <UserName Value="uFK%2Fz%2BkEchpulalnJr1rBw%3D%3D"/> 👈 url-encoded base64 <Password Value="ob7Ue7q%2BkUSZFffy3%2BVfiQ%3D%3D"/> <PersistKey Use="true"/> 4p4uaaq422af3"/> <SessionKey Type="APIF"/> <CryptSessionKey Use="true" Type="SHA1" Value="//////S3mbZSAQAA/LOitv////9IIgS2UgEAAAAQBLY="/> 👈 256-bit base64, not url-encoded <ApplicationKey Value="6a563c3967f147d3adfa454ef913535d0d109ba4b4584914"/> </Request> 👆 192-bit hexadecimal
CryptSessionKey
veeeeeeeeery
suspicious (base64 "//////"
=
0xff 0xff
…)After some days of reverse-engineering (2023)…
_secureRandom()
😰)CameraCryptKey
= RSA-encrypted session
key 🥵char *_secureRandom(int *result) { srand(time(0)); // initialize non-secure(!) RNG with time in seconds(!!) char *target = String_new(20,result); String_format(target,20,"%d",rand()); // get "random"(!!!) number, format as string return _sha1_byte(target,result); // return SHA1 of "random" number string }
char *_sha1_byte(char *buffer, int *result) { int len = strlen(buffer); // get length of string, throw it away char *shabuf = malloc(20); // allocate target buffer char buf[20]; // intermediate buffer on stack memcpy(shabuf, buf, 20); // copy stack buffer to target buffer return shabuf; // return 20 bytes from uninitialized stack(!!!!) }
CryptSessionKey
: session key + IV
encrypted with… nothing at all!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
$ _
WebCheckLogin()
method:char value[512]; GetXmlString(buf,"Response SessionKey",value); strcpy(gWeb.response_session_key,value); GetXmlString(buf,"PersistKey Value",value); ...
GetXmlString()
is not an XML parser<Response SessionKey="{{ sessionkey }}"> <PersistKey Value="{{ persistkey }}"/> <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")
@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']) # parse XML payload 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) # create email message 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)
"~"
as separator between post content and alt-text
for all picturessamsungserver.py
- main / API servicesamsungxml.py
- XML parsing and decryptionmysession.py
- file-based sessionsmastodonlogin.py
- obtain access tokenconfig.toml
- config and credentialsHMAC(recipient)
or
HMAC(social_username)
inotify
INSECURE_DOWNLOAD=true
)Plain-text HTTP, obscure API
Project started as proof of concept, not meant for public deployment!
“Authentication” (password verification could be implemented):
SENDERS = ['Camera@samsungcamera.com', 'georg@op-co.de']
Content routing rules ("mastodon"
,
"store"
, "drop"
):
[ACTIONS]
facebook = "mastodon"
picasa = "store"
"ge0rg@photog.social" = "mastodon"
"store@x.com" = "store"
"test@test.example" = "drop"
Lack of multipart terminator ➛ Patch Flask
TCP segmentation 😡
➛ Patch
Flask to set TCP_CORK
Off-by-4N error:
>>> pcap[0].load.splitlines()[-2]
b'Content-Length: 486270'
>>> sum([len(p.load) for p in pcap[1:]])
486278
>>> 486278 - 486270
8
Hotspot detection 🤬
GET http://www.yahoo.co.kr/ HTTP/1.1 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 (2024)…
partialImage.o.map
Main_Image
, …)partialImage.o.map
is a symbol map for
Main_Image
DevHTTPResponseStart()
checks
Yahoo response
.yahoo.*
domain"yahoo."
at position <= 11len("https://www.") == 12
🤬
# /etc/hosts 192.168.99.1 www.ospserver.net snsgw.samsungmobile.com www.yahoo.co.kr
@app.route('/') def home(): host = (request.headers.get('Host') or "") if host == "www.yahoo.co.kr": response = make_response("YAHOO!", 200) response.set_cookie('samsung', 'hotspot', domain='.yahoo.co.kr') return response
API is running on a 192.168.x.x IP address ➛ run on 10.x.x.x (Seriously!)
Sometimes refuses to connect (“chance 50:50!”)
Firmware is using unknown compression:
Copyright (c) 2<80>^@^@5-2011, Jouni Ma^@^@linen <*@**.**>^@^@and contributors^@^B^@This program ^@^Kf^@^@ree software. Yo! u ^@q dis^C4e it^AF/^@<9c>m^D^@odify^@^Q under theA^@ P+ms of^B^MGNU Gene^A^@ral Pub^@<bc> License^D^E versPy 2.
After some
days weeks of reverse-engineering (2025)…
RELC = Rapid Embedded Lossless data Compression
Calls API known from 2013 PCAP:
GET http://gld.samsungosp.com/ HTTP/1.1 200 OK Accept-Ranges: bytes Content-type: text/html Server: nginx/0.7.65 Content-length: 7 Connection: keep-alive 200 OK
Code in October 2014 firmware:
if ((strstr(response,"ETag: \"") == 0) || (strstr(response,"Server: nginx/") == 0) || (strstr(response,"Content-Length: 0") == 0)) { *auth_passed = -1; return 1; }
"200 OK"
to
""
Ideal deployment platform:
samsung-nx-emailservice
dnsmasq
for API RedirectionGeneration | 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
Sources:
GET /security/sso/initialize/time
(respone in 2012 pastebin)GET /social/columbus/serviceproviders/list?DUID=*snipped*
Social Media Upload Sequence
What the camera does:
POST /<site>/auth/
(username, password) ➛ sessionPOST /<site>/photo/
(filename, album, message) ➛ upload idPOST /upload/upload_id/file
(actual picture, onePOST
per file)Implications: