Giving old Samsung compact and mirrorless cameras (2009-2015) a new life
Ge0rG (@ge0rg@chaos.social)
FrOSCon 2025 (16. August 17:00)
Samsung WiFi cameras
Reverse engineering Samsung camera API services
API Implementation Project
That Hotspot detection!!11
API Deployment
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!
@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 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)
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")
"~"
as separator between post content and alt-text
for all picturesconfig.toml
- config and credentialssamsungserver.py
- main / API servicesamsungxml.py
- XML parsing and decryptionmysession.py
- file-based sessionsmastodonlogin.py
- obtain access tokenINSECURE_DOWNLOAD=true
)SENDERS = ['Camera@samsungcamera.com', 'georg@op-co.de']
"mastodon"
,
"store"
, "shell"
, "drop"
):[ACTIONS] facebook = "mastodon" # default profile picasa = "mastodon.pub" # inherited profie "ge0rg@photog.social" = "mastodon" "store@x.com" = "store" # store to disk "test@test.example" = "drop" # do not forward [MASTODON] # default profile POSTSCRIPT = '#ShittyCameraChallenge #photography #Fotografie #SamsungNX' VISIBILITY = 'unlisted' # 'direct', 'private', 'unlisted', 'public' [MASTODON.pub] # inherits everything from [MASTODON] VISIBILITY = 'public'
Lack of multipart terminator β Patch Flask
TCP segmentation π‘
β Patch
Flask to set TCP_CORK
WB2200F: Off-by-4 error
>>> upload[0].load.splitlines()[-2] 'Content-Length: 486270' >>> sum([len(p.load) for p in upload[1:]]) 486274 >>> 486274 - 486270 4 >>>
Hotspot detection π€¬
API is running on a 192.168.x.x IP address β run on 10.x.x.x (Seriously!)
β
NX-series: just works (http://gld.samsungosp.com
)
β
SH100: not on 192.168.x.x
β WBxxxF
β NX mini
βββββββββββββ
7/27
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
Β Β
β
NX-series: just works (http://gld.samsungosp.com
)
β
SH100: not on 192.168.x.x
β
WBxxxF: Yahoo / MSN - cookie / redirect
β NX mini
βββββββββββββ
21/27
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.
No decompression code in boot loader?!?
After some days
weeks of reverse-engineering with @IgorSkochinsky
(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; }
if host == "gld.samsungosp.com": return make_response("", 200, { 'ETag': '"deadbeef"', 'Server': 'nginx/notreally'})
β
NX-series: just works (http://gld.samsungosp.com
)
β
SH100: not on 192.168.x.x
β
WBxxxF: Yahoo / MSN - cookie / redirect
β
NX mini: changed http://gld.samsungosp.com
βββββββββββββ
27/27
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 | π‘ email API only | β needs more RE | π€· hardware donation
Sources:
𦣠@ge0rg@chaos.social
πΈ @ge0rg@photog.social
π§πΈπ¦¬ @Bruneburg@mastodon.social
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.
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) β session (only once)POST /<site>/photo/
(filename, album, message) β upload URLPUT ...upload_url.../file
(actual picture)Implications: