DS4 Reverse Engineering - Part 6: The serial console from hell
In case you missed previous episodes: Part 1, Part 2, Part 3, Part 4, Part 5
Hello! Welcome back, I think this image is a good recap for this post:
The aim of this post is to hopefully find someone who has already seen this strange Bootloader/DFU protocol I’m going to show here and can help me unbrick my DualShock 4.
Brief introduction
After digging deeper in the ControllerUpdate tool, I found an interesting set of commands:
.field [...] JediRawAPI.ReportIDCode SET_TEST_COMMAND = int32(0xa0)
.field [...] JediRawAPI.ReportIDCode GET_TEST_DATA = int32(0xa4)
The first one seems to execute “something” on the controller, the latter obtain a result code.
How to use them? In this way:
SetTestCommand:
SET 0xa0 [Device : 1 Byte] [Action : 1 Byte] [Data : 2 bytes]
Where Device
can be one of:
Speaker = 1
-
Buttery = 2
(Yes, it’s written “Buttery” in the Controller Update tool! ) Bluetooth = 3
-
Jedi = 4
(Jedi is the codename for the DS4 board)
For each Device
there are some available actions listed in the tool:
- For the
Speaker
we haveSpeakerON = 1, SpeakerOFF = 0
. - For the
Buttery
we haveButteryVoltage = 1, Charging = 2
. - For
Bluetooth
we haveBtTestUART = 1
- For
Jedi
we haveJediReset = 1
, which was already known online and used in some scripts.
GET_TEST_DATA
can be executed after SET_TEST_COMMAND
to obtain some output
from the test.
This is quite interesting because if we send an invalid command and we call GET_TEST_DATA right after, the DS4 replies with 0xffff
.
For example, let’s try Device=4
(Jedi), Action=99
which should not exist:
SET 0xa0 4 99
-
GET 0xa4
->0xffff
, as expected.
The Russian Roulette
GET_TEST_DATA
returns 0xffff on an invalid command. We can use it to spot new hidden functionalities!~ A voice in my head
We know that SET 0xa0 4 1
resets the console.
This is the only action available for Device=4
in the leaked source code from 2013:
// Handler for SET 0xa0
unsigned int usb_set_test_command(int a1, unsigned int a2, unsigned __int8 *a3, int a4)
{
switch ( a1 )
{
// [...]
case 4: // Jedi
if ( a2 == 1 ) {
// Reset
reset_cmd_part1();
reset_cmd_part2();
__dsb(0xFu);
MEMORY[0xE000ED0C] = MEMORY[0xE000ED0C] & 0x700 | 0x5FA0004;
__dsb(0xFu);
while ( 1 )
;
}
goto ERROR;
// ...
}
ERROR:
result = 255;
out_opcode_usb = -1;
out_usbA4 = -1;
last_usbset_result = -1;
break;
EXIT:
return result;
}
You can see in the code above that if we run invalid sub-commands, the result code is 0xff and other variables that are later used in the GET_TEST_DATA are also set to 0xff, which lead to a 0xffff
result.
Do you think they added new “tests” for the Jedi device? Let’s play the russian roulette of trying new actions and see what happens.
SET 0xa0 4 2 [Data=00 01]
-
GET 0xa4 -> 0x04020001
: command executed correctly
Damn, it worked! What happened? No idea!
After dumping the configuration flash I discovered that it wrote 00 01
(payload) at offset 14/15 (0e/0f).
00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
----------------------- -----------------------
Good DS4: 91 4a 00 00 00 00 4e 81 8e 75 a9 2a 01 00 00 b4
Broken DS4 before: 33 b9 00 00 00 00 4e 81 8e 75 a9 2a 00 00 08 ff
Broken DS4 after: 3b b7 00 00 00 00 4e 81 8e 75 a9 2a 00 00 00 01
^^^^^
What does it mean?
Definitely no idea. If the working DS4 has there 00 b4
and the broken one 08 ff
, I can use this command to put 00 b4
there.
Maybe something starts working again.
Why don’t you try another action? Maybe 3…
~ The same voice in my head
SET 0xa0 4 3 [Data=00 01]
GET 0xa4 -> *radio silence*
Unplug and replug the DS4 and the tool doesn’t find the DS4 anymore. Reset the DS4, unplug the battery, plug it into a PS4. Completely dead.
What happened? Let me check into dmesg
(linux kernel messages):
Before sending last command, Vendor=054c Product=09cc (Dualshock 4 V2):
[61273.950162] usb 1-2: new full-speed USB device number 42 using xhci_hcd
[61274.092195] usb 1-2: New USB device found, idVendor=054c, idProduct=09cc, bcdDevice= 1.00
[61274.092211] usb 1-2: New USB device strings: Mfr=1, Product=2, SerialNumber=0
[61274.092218] usb 1-2: Product: Wireless Controller
[61274.092223] usb 1-2: Manufacturer: Sony Interactive Entertainment
After sending the command, Vendor=054c Product=0a9e (???):
[61283.813320] usb 1-2: new full-speed USB device number 43 using xhci_hcd
[61283.954919] usb 1-2: New USB device found, idVendor=054c, idProduct=0a9e, bcdDevice= 0.15
[61283.954936] usb 1-2: New USB device strings: Mfr=1, Product=2, SerialNumber=0
[61283.954943] usb 1-2: Product: Wireless Controller
[61283.954948] usb 1-2: Manufacturer: Sony Interactive Entertainment
[61284.064893] cdc_acm 1-2:1.0: ttyACM0: USB ACM device
[61284.064922] usbcore: registered new interface driver cdc_acm
[61284.064924] cdc_acm: USB Abstract Control Model driver for USB modems and ISDN adapters
What, the DualShock4 is now a serial console?
The serial console from hell
We don’t have anymore a HID device, just a serial console. What can we do with that? We have to guess baud rate, protocol, commands. Maybe is using some common protocol, maybe something custom.
After hours of trying random stuff, reading responses and trying to figure out the protocol, I started connecting the dots.
Here is a script I wrote to “chat” with this serial console: you send hex bytes by typing them and the script shows the responses:
#!/usr/bin/env python3
import sys
import serial
import binascii
from textwrap import wrap
while True:
# Read from stdin
x = input("<al> ")
while len(x) > 0 and x[-1] == '\n': x = x[:-1]
# Send to DS4
s = serial.Serial("/dev/ttyACM0", 230400, timeout=0.1)
s.write(binascii.unhexlify(x))
# Read and show reply
output = binascii.hexlify(s.read(256)).decode('utf-8')
output = ' '.join(wrap(output, 2)) # b'123456' -> '12 34 56'
print("<ds4> %s" % (output, ))
# Close connection
del s
How did I figure out the baud rate? Simple: I tried a random one and it was working. So I tried another one and it was working again. Finally I tried a third one and it was working also that. Then I discovered that it’s like a “virtual serial” over USB, the baud rate is ignored .
Side note: You are probably wondering why the script opens a serial connection inside the loop: it’s because sometimes the DS4 stops responding until a reset. In this way I can manually reset it without restarting the script.
Investigating the protocol
First thing discovered: it’s a byte-based protocol (and not ascii-based, like AT or similar).
If you send an invalid byte, it replies once with an error message in it’s own evilish language “04 10 01 02
” and stops showing that error until a reset of the device.
What are invalid bytes? All of them. Except these: 01, 02, 03, ff. These four bytes seem to be some valid actions that you can do: if you send them, the DS4 waits for other data and, at some point, it replies something.
Let’s play a bit with the chat script shown above, to show you how it works:
$ python3 chat.py
<al> 00
<ds4> 04 10 01 02 # The error "invalid command"
<al> 00
<ds4> # He speaks only once
<al> 00
<ds4> # Yup, very dead
* reset the DS4 *
<al> cc # a random invalid byte
<ds4> 04 10 01 02
<al> cc # try again
<ds4> # dead.
<al> 00
<ds4>
<al> ff # One of the accepted bytes
<ds4> 04 e4 06 02 03 02 00 00 03 # Seems quite talkative...
Let’s try with another accepted byte: 01, which I discovered is a command where the payload is 3/4 bytes.
* reset *
<al> 01
<ds4> # Nothing, he's waiting here for other bytes
<al> 01 # 1
<ds4>
<al> 01 # 2
<ds4>
<al> 01 # 3
<ds4>
<al> 01 # 4
<ds4> 04 0f 04 01 01 01 01 # Here we go!
So basically I spent few days weeks playing with this tool, sending bytes and
reading the output trying to figure out patterns.
How did I enumerate all possible commands? Simple: send a byte, see the response, reset the DS4, repeat.
00 -> 04 10 01 02
01 -> nothing
02 -> nothing
03 -> nothing
04 -> 04 10 01 02
05 to 0xfe -> 04 10 01 02
ff -> 04 e4 06 02 03 02 00 00 03
The protocol
In this section you can find what I understood about this protocol while trying to reboot it into “normal mode” without any success so far.
Responses
Every response from the DS4 starts with the byte 04
. The response structure seems the following one:
04 [Opcode : 1 Byte] [Len : 1 Byte] [Payload : $Len Bytes]
For example, this is the response got after sending the byte ff
:
<al> ff
<ds4> 04 [ e4 ] [ 06 ] [ 02 03 02 00 00 03 ]
Opcode Length Payload, 6 bytes
Sending every other byte except for 01
,02
,03
, ff
, shows the same response once per reboot:
<al> 00
<ds4> 04 [ 10 ] [ 01 ] [ 02 ]
Opcode Length Payload
So opcode 10
represents probably an Error, and 02
is the code for “Invalid command”.
Accepted commands
As I mentioned before, 01, 02, 03, ff
do stuff. I played a lot with them
and it follows a description of everything I discovered.
Command 0xFF
This command is very simple, it’s a single-byte command and does not accept parameters. I think it may be something like “mode change”, but can’t figure out what changes.
It works only once per reboot and this is what happens:
<al> ff
<ds4> 04 e4 06 02 03 02 00 00 03
It replies with e4
with the payload 02 03 02 00 00 03
. Nothing else.
If we send again ff
, the command is ignored.
Command 0x01
This seems to be a command to read/write registers or memory areas.
The syntax of the Command 01 is the following one:
01 [Address : 2 Bytes] [Length : 1 Byte] [Data : $Len Bytes]
For almost all addresses and a zero-length payload, the response has this format:
<al> 01 xx yy 00
<ds4> 04 0f 04 0101xxyy
(where xx yy is the address) .
There are few addresses that, if queried with length 00, show a different output.
For example, look at this list. On the left of ->
you can see what I sent to the DS4. On the right the response:
Message Sent -> Resp|Code|Len|Data
|Addr|Len
01 0d08 00 -> 04 0f 04 12010d08
01 0e08 00 -> 04 0e 06 010e08000000
01 0f08 00 -> 04 0f 04 12010f08
01 1008 00 -> 04 0f 04 12011008
01 1108 00 -> 04 0f 04 12011108
01 010c 00 -> 04 0f 04 1201010c
01 030c 00 -> 04 0e 04 01030c00
01 050c 00 -> 04 0e 04 01050c12
01 080c 00 -> 04 0f 04 1201080c
01 090c 00 -> 04 0e 05 01090c0000
01 0a0c 00 -> 04 0f 04 12010a0c
01 0b0c 00 -> 04 0e 04 010b0c11
01 0d0c 00 -> 04 0f 04 12010d0c
01 110c 00 -> 04 0e 05 01110c1200
01 120c 00 -> 04 0f 04 1201120c
01 130c 00 -> 04 0e 04 01130c00
01 140c 00 -> 04 0e fc 01140c00130c0000000000000000000000000000000[...]000000
01 150c 00 -> 04 0e 06 01150c00a01f
Basically I wrote a script that request all possible 16-bit addresses one after
the other and saves in a file the output only IF the output is different than 04 0f 04 0101xxyy
.
The output file can be found here.
As you can see, there are two possibile response codes: 0f
and 0e
.
- When the code is
0f
, the payload starts with1201
or0101
and it follows again the address. - When the code is
0e
, the payload starts only with01
and then follows the address
01140c00
caught my attention, look at that long list of zeros!
After a reboot of the DS4, I tried to request again 01140c00
and this has been the response:
<al> 01140c00
<ds4> 04 0e fc 01 14 0c 00 4d 54 4b 20 4d 54 33 36 31 30 20 23 30 00 [...] 00 00
Wait, what? Where is my beautiful list of all zeros!?
Anyway, a trained eye can see that the payload is full of printable characters:
$ python
>>> import binascii
>>> binascii.unhexlify("4d544b204d5433363130202330")
b'MTK MT3610 #0'
Oh nice, so address 0x0c14
contains at boot the name of the CPU (MediaTek MT3610).
Why it was all zeros while listing all of them?
Maybe some other command zeroed it out?
Let me try some command before 0x0c14
and see if that long list of zeros appear again:
<al> 01130c00
<ds4> 04 0e 04 01 13 0c 00
<al> 01140c00
<ds4> 04 0e fc 01 14 0c 00 13 0c 00 00 00 00 00 00 00 00 00 00 00
Oh wow, so 01130c00
for some reason zeroes out the output of 01140c00
.
What happens if I add some payload to 0x0c13
?
$ python
>>> import binascii
>>> binascii.hexlify(b"the.al rulez")
b'7468652e616c2072756c657a'
And forge the command:
<al> 01130c0d187468652e616c2072756c657a
<ds4> 04 0e 04 01 13 0c 00
(me feeling like a pro by sending such a long command and getting the expected answer )
Anyway, let’s see if this works:
<al> 01140c00
<ds4> 04 0e fc 01 14 0c 00 18 74 68 65 2e 61 6c 20 72 75 6c 65 7a 0b a4 bf 13
56 5b 65 51 28 c4 55 d3 b1 7b ea cc 13 8a b4 17 a4 57 73 da 30 59 5b a3 dc 9b
8d 63 9f be 15 b5 78 f7 83 3c 04 c4 bd 9d fa 70 29 a6 c9 eb cf 7a 2d 88 fa 42
ed 54 df 0e 71 ea 98 47 db 36 a9 b4 5b 2f d4 c2 c9 83 99 67 cf c2 b7 12 08 ee
19 e2 40 dd ce 0a 3d 31 5c a5 f9 c2 1b 15 51 11 71 ff 38 ed 4e 09 1b e1 6f f9
11 6d a6 3e f1 13 15 ed 6c ff 8c 98 a8 03 e8 ce 9a e1 cb 58 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
which in an hex-dump is like:
00000000 04 0e fc 01 14 0c 00 18 74 68 65 2e 61 6c 20 72 |........the.al r|
00000010 75 6c 65 7a 0b a4 bf 13 56 5b 65 51 28 c4 55 d3 |ulez....V[eQ(.U.|
00000020 b1 7b ea cc 13 8a b4 17 a4 57 73 da 30 59 5b a3 |.{.......Ws.0Y[.|
00000030 dc 9b 8d 63 9f be 15 b5 78 f7 83 3c 04 c4 bd 9d |...c....x..<....|
00000040 fa 70 29 a6 c9 eb cf 7a 2d 88 fa 42 ed 54 df 0e |.p)....z-..B.T..|
00000050 71 ea 98 47 db 36 a9 b4 5b 2f d4 c2 c9 83 99 67 |q..G.6..[/.....g|
00000060 cf c2 b7 12 08 ee 19 e2 40 dd ce 0a 3d 31 5c a5 |........@...=1\.|
00000070 f9 c2 1b 15 51 11 71 ff 38 ed 4e 09 1b e1 6f f9 |....Q.q.8.N...o.|
00000080 11 6d a6 3e f1 13 15 ed 6c ff 8c 98 a8 03 e8 ce |.m.>....l.......|
00000090 9a e1 cb 58 00 00 00 00 00 00 00 00 00 00 00 00 |...X............|
Quite talkative today… the.al rulez
is there but there is a lot of garbage after that.
Right! I forgot \0
at the end of the string.
Unfortunately, the extra-content seems to be completely random and changes at each startup.
If we put \0
in the 0x0c13
call, we obtain a more clean answer:
<al> 01140c00
<ds4> 04 0e fc 01 14 0c 00 18 74 68 65 2e 61 6c 20 72 75 6c 65 7a 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
[...]
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Command 01 is definitely my favorite and needs way more investigation. Maybe it can be a way to escape this serial-hell mode.
Command 0x02
The structure of this command is similar to Command 01, except for the payload length, which is 2 bytes.
02 [Address : 2 Bytes] [Length : 2 Bytes] [Data : $Len Bytes]
Thanks to the “length” we can be sure that the byte ordering is Little Endian.
I haven’t tried this command extensively yet. All my attempts always ended with error 0x08:
<ds4> 04 10 01 08
Command 0x03
The syntax of this command is the same as Command 01:
03 [Address : 2 Bytes] [Length : 1 Byte] [Data : $Len Bytes]
I tried to brute-force all possible addresses with length zero and everytime I got the same error code (0x28). For example:
<al> 03 00 00 00
<ds4> 04 10 01 28
What happens if I use a length greater than zero? Same error code, of course:
<al> 03 00 00 02 ff ff
<ds4> 04 10 01 28
Fun fact of this command: look when the error is emitted if the command is sent byte-by-byte:
<al> 03
<ds4> # waiting
<al> 00 # Addr low
<ds4>
<al> 00 # Addr high
<ds4> # Waiting to know the length
<al> 02
<ds4> 04 10 01 28 # Error shown right here
# But still wants to consume 2 bytes
<al> ff
<ds4> # Waiting for the last byte
<al> ff
<ds4> # Good, back into the main loop
<al> ff
<ds4> 04 e4 06 02 03 02 00 00 03
Recap of the Protocol
There are four accepted commands:
- 0x01:
01 [Addr : 2] [Length : 1] [Data : $Length]
: read/write stuff - 0x02:
02 [Addr : 2] [Length : 2] [Data : $Length]
: often error 0x08 - 0x03:
03 [Addr : 2] [Length : 1] [Data : $Length]
: always error 0x28 - 0xff: replies with
0xe4
with payload02 03 02 00 00 03
Response format:
0x04 [Code : 1] [Length : 1] [Data : $Length]
A list of Code
s found while experimenting:
- 0x0e/0x0f: replied by 0x01
- 0x10: error
- 0xe4: replied by 0xff
Conclusion
Thank you for reading!
At the moment I’m stuck in this serial mode. If you have any knowledge of this protocol or have any idea on how to reboot it again in normal mode, please let me know!!
Little anectode: One day I pressed random buttons on the pad (reset included) for 10-15 minutes and, at some point, the DS4 rebooted into HID/DFU mode. I was so happy that I rebooted it again and.. well, serial console again . Never managed to reboot it again in any other mode.
~~~
If you want to get in touch, drop me an email at ds4@the.al
or ping me on IRC
(the_al@freenode|libera|hackint
) or Discord (the_al
).