2018. szeptember 24.

Operation AC: CTF Without a Flag (Part I)


Situation: We have multiple air conditioner devices all over the office, but in our corporate culture, we want to automate everything. For example, if everyone left the office from a specific room and the last one forgot to turn off the AC device, who cares? Our system will turn it off for you. The sad news is that we can't do this with these devices. We found an extra attachable device to eliminate this disability. Lucky for us, they have an Android application as well. But, there is no API or any documentation. We have a lot of AC devices on the wall, we have a few small devices connected to our AC devices and an Android application. We have a cool office, but it can be way more cooler. Oh, we have one more thing, a lot of smart employees with good hacker mentality.




Take over the control of the corporate air conditioning system.




The Android application is our only entry point. It can communicate with all the AC devices, and we can observe its communication and try to forge our own packets.


The first plan: capture all the TCP packets between the Android application and the Internet. Let's configure our computer as a WiFi Hotspot; connect our phone to this network and try to caption everything with tcpdump. There we are, buy we have a very noisy dump because of all the applications on the phone that send and receive packets.


Once we remove all known destinations, we can identify our target; alas, it has a fancy HTTPS connection.




No problem, we can build our own MitM attack with a self-created SSL certificate. Let's generate one:


  openssl req -new -x509 \
    -keyout test-new-key.pem \
    -out test-new-cert.pem


Now we can build the proxy application to listen on a given port with our own SSL certificate.


  cert = OpenSSL::X509::Certificate.new(
) pkey = OpenSSL::PKey::RSA.new(
server = WEBrick::HTTPServer.new(
  :Port => 443,
  :SSLEnable => true,
  :SSLCertificate => cert,
  :SSLPrivateKey => pkey
SERVER_URL = 'https://mapp.appsmb.com'
def proxy_request(path, payload)
    payload = "" if payload.nil?
    payload = Hash[URI.decode_www_form(payload)]

    uri = URI("#{SERVER_URL}#{path}")
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = (uri.scheme == 'https')
    http.verify_mode = OpenSSL::SSL::VERIFY_NONE

    request = Net::HTTP::Post.new(uri)
    response = http.request(request)

    puts "<- Requested path: #{path}"
    puts "<- Payload: #{payload}"
    puts "-> Response body: #{response.body}"

    puts '-------------------------'

server.mount_proc '/' do |req, res|
    res.body = proxy_request(
        req.request_line.split(' ')[1],
trap 'INT' do server.shutdown end


Note: I think now you can figure out our target form this URL: mapp.appsmb.com. Yes, it's a Midea device. It’s not a secret, but I was trying to avoid revealing it for a while..


Next, redirect all traffic to our laptop with a specific destination. Easy one with dnsmasq:



At this point, we don't have to keep our WiFi Hotspot alive, because we can simply change the DNS server on our phone.


Sad news again: The application is not happy. It can't connect to the server because we have an invalid certificate. So the client side of their certificate is embedded into the application. We have to patch the Android application.


 # Generate the client cert openssl x509 \
    -in test-cert.pem \
    -out mapp.appsmb.com.crt
# Make a copy cp NetHomePlus.apk NetHomePlus-ssl-injected.apk
# Figure out where is the cert inside the .apk unzip -l NetHomePlus-ssl-injected.apk | grep crt # -> assets/mapp.appsmb.com.crt
# Create structure for the zip file mkdir assets && cp mapp.appsmb.com.crt assets
# Add our own SSL cert zip -ur NetHomePlus-ssl-injected.apk assets
# Delete all META-INF from the .apk zip -d NetHomePlus-ssl-injected.apk META-INF/\*
# Generate a keystore to sign the .apk keytool -genkey -v \
    -keystore my-release-key.keystore \
    -alias alias_name \
    -keyalg RSA -keysize 2048 \
    -validity 10000
# Sign the .apk jarsigner -verbose \
    -sigalg SHA1withRSA \
    -digestalg SHA1 \
    -keystore my-release-key.keystore \
    NetHomePlus-ssl-injected.apk \


Huh. Let's try again. Transfer the new .apk onto the phone and start. It works. Now we can see everything. Signing in is a simple POST request with the username and the password after which the response is a JSON object with a sessionId, userId and accessToken. But wait... The password in the POST request is not our password. It's something strange, looks like a hash.


Sign in


Right before the user/login request, there is a different one that hits user/login/id/get with only one parameter and it's our email address (username) and it returns with an ID.


The password in the login request seems like a hash. If we look at it closer, it's maybe a SHA hash, but simply hashing our password is not enough. We can try to append our userId at the beginning and at the end of our password before hashing, but it does not work.


Seems a dead end.


Decode an Android app


No clue what we can do, but can we decode the .apk? Yes, of course.


Note: If you don't have radare2 installed on your system, I can only recommend you to install it. It's a very handy tool. We will use dex2jar and it's easy to install with the package manager of radare2: r2pm -i dex2jar. Another mentionable part of radare2 is rax2, it helped me a lot during this mission.


 # Extract classes.dex (Dalvik dex file version) unzip -p NetHomePlus-ssl-injected.apk classes.dex
# Convert dex to jar dex2jar classes.dex 

Now we have a .jar file. With JD-GUI we can open and browse the source code. Ok, not really, but close enough to understand the basic logic behind the scenes.


With newer java we have to tweak a bit to start JD-GUI:


java \
    --add-opens java.base/jdk.internal.loader=ALL-UNNAMED \
    --add-opens jdk.zipfs/jdk.nio.zipfs=ALL-UNNAMED \
    -jar jd-gui-1.4.0.jar \


In the BaseAPI.class file we can see a lot of interesting constants like appKey, appId and src. They will be important later, but back to the sign in password hashing. With Search, we can look up where we can find a string for the login URL.

In IDataPush.class we can see a reference on a package: com.midea.msmartsdk.common.datas.DataPushUserLogin, but after checking the class, it's not so helpful.


Note: It's basically a guessing game. Now I try to describe (somehow) how to jump around and find information crumbs.


Adventure Time! We can do nothing, but walk around and check files with interesting names like EncodeAndDecodeUtils.class. It has a few very promising functions like encodeAES, encodeMD5, encodeSHA and decodeAES. We can assume that we have to use most of them to make proper packets. Let's start with encodeSHA because the sent password was not an md5 hash (it was too long). It can be AES or SHA, but we have no secretKey yet, so there is nothing in our hands that can be a secret to mask the password.


In DataAccount.class we use encodeSHA inside a setUserPwd function. Oh wow, it seems we found something. And... no, useless, we can't find where we use setUserPwd, but it strengthens the thesis: It's a SHA hash.


In DeviceRequest.class we can see how it appends sign= into the URL, this can be useful later.


Oh, an interesting class again: UserRequest.class. Quick scroll and we can see there is more logic on how to sign in. Let's check one with loginAccount and password because we saw those in the request. The getUserLogin function seems a good one, it calls Util.encodeSHA256Ex(paramString2).


  public static String encodeSHA256Ex(String paramString)
    String str1 = (String)SharedPreferencesUtils.getParam(MSmartSDK.getInstance().getAppContext(), Const.SP_KEY_LOGIN_ID, "");
    String str2 = MSmartSDK.getInstance().getAppKey();
    return EncodeAndDecodeUtils.getInstance().esha(paramString, str1, str2);


Ok, we don't know what esha does, but no problem, it has to be a SHA hash and we have 3 string components. While we walked around we found a function named encodePasswordAfterSHA256. It looks the same as encodeSHA256Ex, but it calls eshaWithoutEncode instead of esha. So maybe password can be SHA-hashed before as well. Brute-force time! It’s a possible dead end again, but we have to try everything. If it does not work, we can read those ugly classes and functions again.


 require 'digest/sha2'
# We know it from BaseAPI.class app_key = '3742e9e5842d4ad59c2db887e12449f9' # we know it from the user/login/id/get response login_id = '111111' # We know it... because we know it password = 'asdfghjkl'
# We want to generate this hash somehow target = '5199c3678177a450c364e4472d290ae4a96d04e603c23c38a6f939da31946924'
[app_key, login_id, password].permutation do |current|
    str = "#{current[0]}#{current[1]}#{current[2]}"
    if (::Digest::SHA2.new << str).to_s == target
      puts "FOUND: #{current[0]} + #{current[1]} + #{current[2]}"
      puts "NOT: #{current[0]} + #{current[1]} + #{current[2]}"
password = (::Digest::SHA2.new << password).to_s [app_key, login_id, password].permutation do |current|
    str = "#{current[0]}#{current[1]}#{current[2]}"
    if (::Digest::SHA2.new << str).to_s == target
      puts "FOUND: #{current[0]} + #{current[1]} + #{current[2]}"
      puts "NOT: #{current[0]} + #{current[1]} + #{current[2]}"


"Modify, Customize, Rubify"


 NOT: 3742e9e5842d4ad59c2db887e12449f9 + 111111 + asdfghjkl NOT: 3742e9e5842d4ad59c2db887e12449f9 + asdfghjkl + 111111 NOT: 111111 + 3742e9e5842d4ad59c2db887e12449f9 + asdfghjkl NOT: 111111 + asdfghjkl + 3742e9e5842d4ad59c2db887e12449f9 NOT: asdfghjkl + 3742e9e5842d4ad59c2db887e12449f9 + 111111 NOT: asdfghjkl + 111111 + 3742e9e5842d4ad59c2db887e12449f9 NOT: 3742e9e5842d4ad59c2db887e12449f9 + 111111 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 NOT: 3742e9e5842d4ad59c2db887e12449f9 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 + 111111 NOT: 111111 + 3742e9e5842d4ad59c2db887e12449f9 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 FOUND: 111111 + 5c80565db6f29da0b01aa12522c37b32f121cbe47a861ef7f006cb22922dffa1 + 3742e9e5842d4ad59c2db887e12449f9 


It seems our guess was correct, so go and reproduce the same logic in Ruby:


  def encrypt_password(password, login_id, app_key)
    pass = (::Digest::SHA2.new << password).to_s
    (::Digest::SHA2.new << "#{login_id}#{pass}#{app_key}").to_s


If we analyze all the queries, we can see there are a few parameters in common:


  • appId = 1017 (we found it as a constant as well)
  • format = 2 (we found it as a constant as well: JSON)
  • clientType = 1 (we found it as a constant as well: Android)
  • src = 17 (we found it as a constant as well)
  • language (obvious)
  • stamp (obvious: timestamp)
  • sign (sign, but unknown logic yet)


Now we know a sequence of queries (plus the common parameters):


  1. https://mapp.appsmb.com/v1/user/login/id/get
  2. https://mapp.appsmb.com/v1/user/login
        :password=>"long hash"
  3. https://mapp.appsmb.com/v1/homegroup/list/get
        :sessionId=>"long hash",
  4. https://mapp.appsmb.com/v1/appliance/list/get
        :homegroupId=>"group id from homegroup/list/get",
        :sessionId=>"long hash"


After these steps, we get a full device list. The only unknown part is the sign on our request, but we already found some clues in DeviceRequest.class.



What do we have in DeviceRequest.class? A function: getRequestUrl.


Ok, it's ugly. Let's clean it up a bit (no, I will not copy the whole function here):


 str1 = '/v1/' + 'user/login/id/get' # key1=value&key2=value localObject1 = str1 + params.join('&') paramList = localObject1.to_s localObject1 += app_key localObject1 = sha256(localObject1) 


Still ugly, but basically the whole signing procedure is like this:


 sha256(path + params + app_key) 


"Modify, Customize, Rubify"


  def sign(path, args, app_key)
    query = args.map { |k, v| "#{k}=#{v}" }.to_a.sort.join('&')
    content = "#{path}#{query}#{app_key}"
    (::Digest::SHA2.new << content).to_s


At this point, we know everything about how to sign-in and how to list devices.


 require 'digest/sha2' require 'json' require 'net/http' require 'openssl'
class Client
    SERVER_URL  = 'https://mapp.appsmb.com/v1'
    APP_ID      = '1017'
    SRC         = '17'
    APP_KEY     = '3742e9e5842d4ad59c2db887e12449f9'
    LANGUAGE    = 'en_US'
    CLIENT_TYPE = 1       # Android
    FORMAT      = 2       # JSON

    def initialize(email, password)
        @email    = email
        @password = password

        @current = nil

    def login
        login_id = user_login_id_get['loginId']

        @current = api_request(
            loginAccount: @email,
            password: encrypt_password(login_id)

    def appliance_list
        response = api_request(
            homegroupId: default_home['id']

    def user_login_id_get
        api_request('user/login/id/get', loginAccount: @email)

    def default_home
        @default_home ||= api_home_list['list'].select do |h|
            h['isDefault'].to_i == 1

    def api_home_list

    def encrypt_password(login_id)
        pass = (::Digest::SHA2.new << @password).to_s
        (::Digest::SHA2.new << "#{login_id}#{pass}#{APP_KEY}").to_s

    def sign(path, args)
        query = args.map { |k, v| "#{k}=#{v}" }.to_a.sort.join('&')
        content = "#{path}#{query}#{APP_KEY}"
        (::Digest::SHA2.new << content).to_s

    def api_request(endpoint, **args)
        args = {
            appId: APP_ID, format: FORMAT, clientType: CLIENT_TYPE,
            language: LANGUAGE, src: SRC,
            stamp: Time.now.strftime('%Y%m%d%H%M%S')

        args[:sessionId] = @current['sessionId'] unless @current.nil?

        path = "/#{SERVER_URL.split('/').last}/#{endpoint}"
        args[:sign] = sign(path, args)

        result = send_api_request(URI("#{SERVER_URL}/#{endpoint}"), args)


    def send_api_request(uri, args)
        Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
            request = Net::HTTP::Post.new(uri)

            result = JSON.parse(http.request(request).body)
            raise result['msg'] unless result['errorCode'] == '0'

client = Client.new('username', 'password') client.login devices = client.appliance_list devices.each do |device|
  print "[id=#{device['id']} type=#{device['type']}]"
  print " #{device['name']} is "
  print 'not ' if device['onlineStatus'] != '1'
  print 'online and '
  print 'not ' if device['activeStatus'] != '1'
  puts 'active.'


And finally, we have a basic client with a "simple" login mechanism and we can list all our devices.




If you read this post, it seems easy, but if you try to do it without a guide, it's much longer. I can't say it's harder but much longer.


OK, we can't manage our air conditioning system yet at this point. But, with this Client class, we are close to accomplishing our mission. We have to track back how to fetch information about a specific device, how we can turn them on/off, and how to set its target temperature.


Sounds easy, but it will be much longer with more gripping twists than what you just read.


To give you a sneak peek, we have to implement our own block AES encoder and decoder, and we have to do a lot of bit-shifting.

Related posts

2018. november 27.

An extensive tutorial on how to use HashiCorp's go-plugin to extend your own project.