├── FIRMWARE_DUMP.md ├── LICENSE ├── Protocol.md ├── README.md └── examples ├── cpp ├── Main.cpp ├── Vestaboard.cpp └── Vestaboard.h └── python └── main.py /FIRMWARE_DUMP.md: -------------------------------------------------------------------------------- 1 | # How to dump the firmware (Draft) 2 | ## Procedure with HW access 3 | 4 | 1. Remove the RPi Compute Module (CM3+, 8GB) from the Vestaboard (Use some alcohol to remove the tamper sticker without triggering it) 5 | 2. Use a CM3+ to supply power to the module and to mount the filesystem 6 | 3. Create a raw dump using dd (`dd if=/dev/sdb of=vestaboard_fs.bin bs=16M`) 7 | 8 | ## Procedure with ssh access 9 | 10 | 1. Connect via SSH as root (Use mentioned private key from the README.md) 11 | 2. Dump each parition using dd and save it somewhere as a backup 12 | 13 | ## Further analysis 14 | I've cloned the firmware onto my own CM3+ so I don't have to work on the original product, and I would suggest you do the same, 15 | just get yourself an CM3+ with 8GB eMMC (more should prob. work too), write the vestaboard_fs.bin onto it and plug that into the board. 16 | 17 | You can also mount the filesystem in linux and modify it before putting it back in, your imagination is your limit, add your own SSH keys, 18 | startup scripts etc 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 EngineOwning Software UG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Protocol.md: -------------------------------------------------------------------------------- 1 | # Protocol (Transport, Layer 1) 2 | 3 | The Raspberry Compute Module 3+ communicates with the on-board display/flap-controller using a simple byte-based serial protocol using the on-board serial bus. 4 | 5 | 6 | ## Serial Bus 7 | 8 | The serial bus itself is accessible via the `/dev/ttyAMA0` device. 9 | | Setting | Value | 10 | |--|--| 11 | | Baudrate | 38400 Baud In/out | 12 | | Parity | Disabled | 13 | | Stopbit | 1 Stop bit | 14 | | Character Size(bits) | 8 | 15 | | RTS/CTS Hardware flow control | Disabled 16 | 17 | 18 | 19 | # Protocol (Data, Layer 2) 20 | 21 | Packets always start with a magic byte (`0xFF`), contain an address, some flags (so far unknown), a payload and its length, and end with a CRC32 of the packet itself. 22 | 23 | ## Structure 24 | ### Packet Structure 25 | | Offset | Length | Field | 26 | |--|--|--| 27 | | 0 | 1 | Magic Byte (0xFF) | 28 | | 1 | 2 | Address (For most packets the Column ID, 1-22) 29 | | 3 | 1 | Flags 30 | | 4 | 2 | Payload Length 31 | | 6 | variable | \ 32 | | 6 + len(payload) | 4 | Checksum(CRC32) 33 | 34 | ### Payload Structure 35 | | Offset | Length | Field | 36 | |--|--|--| 37 | | 0 | 1 | Packet ID | 38 | | 1 | variable | \ 39 | 40 | 41 | 42 | ## Checksum (CRC32) 43 | 44 | Before a packet gets sent, the last 4 bytes are set to 0xFF and the whole packet gets checksummed using CRC32, the result is then stored in the last 4 bytes. 45 | 46 | # Packets 47 | 48 | ## Packet IDs 49 | | Packet ID | Name | 50 | |--|--| 51 | | 0x00 | Unknown/Not used | 52 | | 0x01 | Set Target | 53 | | 0x02 | Arm Column | 54 | | 0x03 | Go | 55 | | 0x04 | Ping | 56 | | 0x05 | Write Register | 57 | | 0x06 | Read Register | 58 | 59 | ## 0x00 - Unknown 60 | 61 | This type is currently not used, or not referenced anywhere 62 | 63 | ## 0x01 - Set Target 64 | 65 | | Offset | Length | Field | 66 | |--|--|--| 67 | | 0 | 1 | Packet ID(0x01) | 68 | | 1 | 7 | Column data as bytes | 69 | 70 | ### Notes 71 | For some reason there are not 6 but 7 characters in each column, comments in the original firmware might indicate a not-released version of Vestaboard with 7x22 (or even 7x23) flaps. 72 | 73 | 74 | ## 0x02 - Arm Column 75 | 76 | | Offset | Length | Field | 77 | |--|--|--| 78 | | 0 | 1 | Packet ID(0x02) | 79 | 80 | ## 0x03 - Go 81 | 82 | | Offset | Length | Field | 83 | |--|--|--| 84 | | 0 | 1 | Packet ID(0x03) | 85 | 86 | ## 0x04 - Ping 87 | 88 | | Offset | Length | Field | 89 | |--|--|--| 90 | | 0 | 1 | Packet ID(0x04) | 91 | | 1 | 2 | Null bytes(0x00 0x00) | 92 | 93 | ## 0x05 - Write Register 94 | 95 | | Offset | Length | Field | 96 | |--|--|--| 97 | | 0 | 1 | Packet ID(0x05) | 98 | | 1 | 1 | Register ID | 99 | | 2 | 2 | Register Value | 100 | 101 | ## 0x06 - Read Register 102 | 103 | | Offset | Length | Field | 104 | |--|--|--| 105 | | 0 | 1 | Packet ID(0x06) | 106 | | 1 | 1 | Register ID | 107 | 108 | 109 | # Registers 110 | 111 | Coming soon 112 | 113 | # Flap Characters 114 | 115 | ## Colors 116 | | Value | Color | 117 | |--|--| 118 | | 0x00 | Black | 119 | | 0x01 | White | 120 | | 0x02 | Red | 121 | | 0x03 | Orange | 122 | | 0x04 | Yellow | 123 | | 0x05 | Green | 124 | | 0x06 | Blue | 125 | | 0x07 | Purple | 126 | 127 | 128 | ## How to set flaps 129 | 130 | The process is simple, after opening a connection to the flap-controller, simply send 1-22 SetTarget packets, followed by 1-22 Arm packets, and commit using a Go packet. 131 | 132 | ```mermaid 133 | sequenceDiagram 134 | Pi ->> Board: SetTarget[ColId(Address): 1, Data: ('AAAAAA ') 135 | Board ->> Pi: ACK 136 | Pi ->> Board: Arm[ColId(Address): 1] 137 | Board ->> Pi: ACK 138 | Pi ->> Board: SetTarget[ColId(Address): 2, Data: ('BBBBBB ') 139 | Board ->> Pi: ACK 140 | Pi ->> Board: Arm[ColId(Address): 2] 141 | Board ->> Pi: ACK 142 | Pi ->> Board: SetTarget[ColId(Address): 3, Data: ('CCCCCC ') 143 | Board ->> Pi: ACK 144 | Pi ->> Board: Arm[ColId(Address): 3] 145 | Board ->> Pi: ACK 146 | Pi ->> Board: Go[Address: 1] 147 | Board ->> Pi: ACK 148 | ``` 149 | 150 | ### Note 151 | There has to be a small delay of about 40000μs between each packet, otherwise the board seems to go out-of-sync and sometimes lock up. 152 | 153 | ### Result 154 | ![](https://i.imgur.com/4hCTEyI.png) 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vestaboard-Reverse-Engineering 2 | 3 | ## Protocol 4 | See [here](https://github.com/EngineOwningSoftware/Vestaboard-Reverse-Engineering/blob/main/Protocol.md) 5 | ## Examples 6 | [C++](https://github.com/EngineOwningSoftware/Vestaboard-Reverse-Engineering/blob/main/examples/cpp/Main.cpp) 7 | [Python](https://github.com/EngineOwningSoftware/Vestaboard-Reverse-Engineering/blob/main/examples/python/main.py) 8 | 9 | ## ToDo 10 | 11 | - [ ] Document firmware dumping process 12 | - [ ] Document vulnerabilities in original firmware 13 | - [ ] Take a deeper look at the ACK/Response packets sent from the board 14 | - [ ] Clean-up and release custom firmware that offers a local device-level API 15 | - [ ] Take a deeper look into all the docker containers running in the original firmware 16 | 17 | ## Credits 18 | 19 | - [Vestaboard](https://www.vestaboard.com/), for creating the board itself 20 | - [Bennet Huch](https://twitter.com/B3nn0_DE), author of this project 21 | 22 | ## Authors note 23 | The hardware of the board is very well made, the look and feel of it is extraordinary, sadly I can't say the same about the firmware running on it, there are 5 docker containers running next to each other, highly redundant code. Legacy code with legacy API is left unused in many parts of the firmware, the whole software stack is a mix of Python, Java and Bash. The code quality of the python code is not very good, there are lots of things Vestaboard could (and hopefully will) improve over the next few months. 24 | 25 | ~~There is one big flaw in the firmware that needs to be disclosed to the vendor first, currently I don't really have the time to report it. (Vestaboard feel free to contact me via Twitter [linked above] or via `contact [at] bennethu [dot] ch`).~~ 26 | Vestabaord did in fact contact me (23.03.2022), I've provided them with the flaw in the firmware, but they didn't seem to care (at least not so much that they had the time to respond to my mail), so here is the flaw (Basically verbatim the mail I sent): 27 | 28 | ``` 29 | So basically I noticed that either the rootfs or one of the docker containers has a "vestaboard-root-key", a SSH private key. 30 | The public part of that key is in the rootfs /root/.ssh/authorized_keys, that key is used to issue a reboot from within the install.sh (line 34 and line 61). 31 | 32 | As I only have one Vestaboard I can't verify that the key is not generated per-device, but I can't seem to find any indications that its generated on first boot. 33 | 34 | Per default the SSH daemon listens on 0.0.0.0:22, and allows root login via key. 35 | 36 | If the key is indeed shared among all Vestaboards, it would pose a big threat to all customers, as it would only require to be on the same network as the Vestaboard to modify the firmware, install a backdoor(thus allowing persistent network access), or maybe even bricking the device. 37 | ``` 38 | 39 | I might release the private key too in a bit, when I'm back home. 40 | 41 | Thats all for now, I hope there are at least a few users who will be able to create something with this. 42 | 43 | ## License 44 | 45 | This work is licensed under the MIT license, as included in the [LICENSE](LICENSE) file. 46 | -------------------------------------------------------------------------------- /examples/cpp/Main.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | #include "Vestaboard.h" 5 | 6 | int main(int argc, char* argv[]) 7 | { 8 | printf("[+] Loading Vestaboard Device-Level API!\n"); 9 | if(!board.open()) 10 | { 11 | printf("[!] Vestaboard::open(); failed!\n"); 12 | return 0; 13 | } 14 | 15 | board.clear(); 16 | 17 | usleep(1000000 * 10); // Wait 10 seconds 18 | 19 | uint8_t colRedGreen[] = {0x05, 0x02, 0x05, 0x02, 0x05, 0x02}; 20 | for(int col = 1; col <= 22; col++) 21 | { 22 | board.setCol(col, colRedGreen); 23 | board.armCol(col); 24 | } 25 | board.go(); 26 | 27 | return 0; 28 | } 29 | -------------------------------------------------------------------------------- /examples/cpp/Vestaboard.cpp: -------------------------------------------------------------------------------- 1 | #include "Vestaboard.h" 2 | #include "CRC.h" 3 | 4 | Vestaboard::Vestaboard(std::string serialPortName) : m_portName(serialPortName) 5 | {} 6 | 7 | bool Vestaboard::open() 8 | { 9 | // Connect to serial port 10 | printf("[+] Connecting to '%s'\n", m_portName.c_str()); 11 | m_serialPort = ::open(m_portName.c_str(), O_RDWR|O_NOCTTY|O_LARGEFILE); 12 | if (m_serialPort < 0) 13 | { 14 | printf("[!] Failed to connect to '%s'\n", m_portName.c_str()); 15 | return false; 16 | } 17 | printf("[+] Connected!\n"); 18 | 19 | // Setup serial params 20 | struct termios tty; 21 | if(tcgetattr(m_serialPort, &tty) != 0) 22 | { 23 | printf("[!] Failed to read config from serial port\n"); 24 | return false; 25 | } 26 | 27 | tty.c_cflag &= ~PARENB; 28 | tty.c_cflag &= ~CSTOPB; 29 | tty.c_cflag &= ~CSIZE; 30 | tty.c_cflag |= CS8; 31 | tty.c_cflag &= ~CRTSCTS; 32 | 33 | tty.c_lflag &= ~ICANON; 34 | tty.c_lflag &= ~ECHO; 35 | tty.c_lflag &= ~ECHOE; 36 | tty.c_lflag &= ~ECHONL; 37 | tty.c_lflag &= ~ISIG; 38 | tty.c_iflag &= ~(IGNBRK|BRKINT|PARMRK|ISTRIP|INLCR|IGNCR|ICRNL); 39 | 40 | tty.c_oflag &= ~OPOST; 41 | tty.c_oflag &= ~ONLCR; 42 | 43 | tty.c_cc[VTIME] = 0; 44 | tty.c_cc[VMIN] = 0; 45 | 46 | 47 | cfsetispeed(&tty, B38400); 48 | cfsetospeed(&tty, B38400); 49 | 50 | if(tcsetattr(m_serialPort, TCSANOW, &tty) != 0) 51 | { 52 | printf("[!] Failed to read config from serial port\n"); 53 | return false; 54 | } 55 | 56 | int dtr = TIOCM_DTR; 57 | ioctl(m_serialPort, TIOCMBIS, &dtr); 58 | 59 | int rts = TIOCM_RTS; 60 | ioctl(m_serialPort, TIOCMBIS, &rts); 61 | 62 | int flsh = TCIFLUSH; 63 | ioctl(m_serialPort, TCFLSH, TCIFLUSH); 64 | 65 | return true; 66 | } 67 | 68 | void Vestaboard::setRTS(bool value) 69 | { 70 | int rts = TIOCM_RTS; 71 | if(value) 72 | ioctl(m_serialPort, TIOCMBIS, &rts); 73 | else 74 | ioctl(m_serialPort, TIOCMBIC, &rts); 75 | } 76 | 77 | void Vestaboard::L1_sendPacket(uint8_t* payload, uint16_t size) 78 | { 79 | setRTS(false); 80 | 81 | unsigned char payloadByte = 0xFF; 82 | write(m_serialPort, &payload[0], 0x1); 83 | 84 | for(int i = 1; i < size; i++) 85 | { 86 | auto payloadByte = payload[i]; 87 | if(payloadByte == 0xFE || payloadByte == 0xFF) 88 | { 89 | auto escapeByte = 0xFE; 90 | write(m_serialPort, &escapeByte, 0x1); 91 | usleep(1500); 92 | } 93 | 94 | write(m_serialPort, &payloadByte, 0x1); 95 | usleep(1500); 96 | } 97 | setRTS(true); 98 | 99 | // TODO: Handle buffer, check size etc 100 | char readBuffer[128]{}; 101 | read(m_serialPort, &readBuffer, 6); 102 | int totalLength = (readBuffer[5] << 8) + readBuffer[4]; 103 | read(m_serialPort, &readBuffer, totalLength + 4); 104 | } 105 | 106 | bool Vestaboard::L2_sendPacket(uint16_t addr, uint8_t flags, uint8_t* payload, uint16_t payloadSize) 107 | { 108 | // Alloc buffer 109 | uint16_t packetLength = 10 + payloadSize; 110 | char* sendBuffer = (char*)malloc(packetLength); 111 | if(!sendBuffer) 112 | return false; 113 | 114 | // Wipe buffer 115 | memset(sendBuffer, 0x00, packetLength); 116 | 117 | // Setup buffer, TODO: Struct 118 | sendBuffer[0] = 0xFF; 119 | sendBuffer[1] = addr & 0xFF; 120 | sendBuffer[2] = addr >> 0x8; 121 | sendBuffer[3] = flags; 122 | sendBuffer[4] = payloadSize & 0xFF; 123 | sendBuffer[5] = payloadSize >> 8; 124 | memcpy(sendBuffer + 6, payload, payloadSize); 125 | memset(sendBuffer + 6 + payloadSize, 0xFF, 4); 126 | 127 | uint32_t crcValue = CRC::Calculate(sendBuffer, packetLength - 4, CRC::CRC_32()); 128 | memcpy(sendBuffer + 6 + payloadSize, &crcValue, sizeof(crcValue)); 129 | 130 | L1_sendPacket((uint8_t*)sendBuffer, packetLength); 131 | 132 | // Free buffer 133 | free(sendBuffer); 134 | 135 | return true; // TODO: Check if send failed 136 | } 137 | 138 | bool Vestaboard::L2_sendCommand(uint8_t addr, uint8_t commandId) 139 | { 140 | uint8_t packet[] = {commandId}; 141 | 142 | return L2_sendPacket(addr, 0, packet, sizeof(packet)); 143 | } 144 | 145 | bool Vestaboard::setCol(uint8_t colId, uint8_t* colData) 146 | { 147 | // TODO: Struct? 148 | uint8_t l2_buffer[] = { 149 | 0x01, // Command - Set Target 150 | 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, // 6 Cols 151 | 0x20 // Overhang 152 | }; 153 | memcpy(l2_buffer + 1, colData, 6); 154 | 155 | L2_sendPacket(colId, 0x00, l2_buffer, sizeof(l2_buffer)); // Set Target 156 | usleep(40000); // Needed, else we get desynced 157 | 158 | return true; 159 | } 160 | 161 | bool Vestaboard::armCol(uint8_t colId) 162 | { 163 | bool success = L2_sendCommand(colId, 0x02); 164 | usleep(50000); // Needed, or we get desynced 165 | 166 | return success; 167 | } 168 | 169 | bool Vestaboard::go() 170 | { 171 | bool success = L2_sendCommand(0x01, 0x03); 172 | usleep(50000); // Needed, or we get desynced 173 | 174 | return success; 175 | } 176 | 177 | void Vestaboard::clear() 178 | { 179 | for(int col = 1; col <= 22; col++) 180 | { 181 | uint8_t colClear[6]{}; 182 | setCol(col, colClear); 183 | armCol(col); 184 | } 185 | go(); 186 | } 187 | -------------------------------------------------------------------------------- /examples/cpp/Vestaboard.h: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | class Vestaboard 11 | { 12 | public: 13 | Vestaboard(std::string serialPortName); 14 | 15 | bool open(); 16 | 17 | bool setCol(uint8_t colId, uint8_t* colData); 18 | bool armCol(uint8_t colId); 19 | 20 | bool go(); 21 | 22 | void clear(); 23 | private: 24 | void L1_sendPacket(uint8_t* payload, uint16_t size); 25 | bool L2_sendPacket(uint16_t addr, uint8_t flags, uint8_t* payload, uint16_t payloadSize); 26 | bool L2_sendCommand(uint8_t addr, uint8_t commandId); 27 | 28 | void setRTS(bool value); 29 | 30 | std::string m_portName; 31 | int m_serialPort; 32 | }; 33 | -------------------------------------------------------------------------------- /examples/python/main.py: -------------------------------------------------------------------------------- 1 | import serial 2 | import serial.rs485 3 | import crc 4 | import time 5 | 6 | serialPort = serial.Serial('/dev/ttyAMA0', 38400) 7 | 8 | def L1_sendPacket(payload): 9 | serialPort.rts = False 10 | serialPort.write([payload[0]]) 11 | for c in payload[1:]: 12 | if c == 0xfe or c == 0xff: 13 | serialPort.write([0xfe]) 14 | serialPort.write([c]) 15 | time.sleep(0.01) 16 | serialPort.rts = True 17 | 18 | returnData = bytearray(serialPort.read(6)) 19 | payloadLength = (returnData[5] << 8) + returnData[4] 20 | serialPort.read(payloadLength + 4) 21 | 22 | def L2_sendPacket(addr, flags, payload): 23 | packetLength = 10 + len(payload) 24 | buffer = bytearray(packetLength) 25 | 26 | buffer[0] = 0xFF # Magic Byte 27 | buffer[1] = addr & 0xFF # Addr Lower 28 | buffer[2] = addr >> 0x8 # Addr Upper 29 | buffer[3] = flags # Flags 30 | buffer[4] = len(payload) & 0xFF # Payload Len Lower 31 | buffer[5] = len(payload) >> 8 # Payload Len Upper 32 | buffer[6:6+len(payload)] = payload # Copy payload 33 | buffer[6+len(payload)+0] = 0xFF # Dummy CRC Byte 1 34 | buffer[6+len(payload)+1] = 0xFF # Dummy CRC Byte 2 35 | buffer[6+len(payload)+2] = 0xFF # Dummy CRC Byte 3 36 | buffer[6+len(payload)+3] = 0xFF # Dummy CRC Byte 4 37 | 38 | crc32 = crc.CRC32(buffer, packetLength - 4) # Calculate CRC 39 | 40 | buffer[6+len(payload)+0] = (crc32 >> 0) & 0xFF # CRC Byte 1 41 | buffer[6+len(payload)+1] = (crc32 >> 8) & 0xFF # CRC Byte 2 42 | buffer[6+len(payload)+2] = (crc32 >> 16) & 0xFF # CRC Byte 3 43 | buffer[6+len(payload)+3] = (crc32 >> 24) & 0xFF # CRC Byte 4 44 | 45 | L1_sendPacket(buffer) 46 | 47 | def sendCol(colId, colBuffer): 48 | payload = bytearray(8) 49 | payload[0] = 0x01 # Set Target 50 | payload[1:1+len(colBuffer)] = colBuffer 51 | payload[7] = 0x20 52 | 53 | L2_sendPacket(colId, 0, payload) 54 | 55 | def armCol(colId): 56 | payload = bytearray(1) 57 | payload[0] = 0x02 # Arm 58 | 59 | L2_sendPacket(colId, 0, payload) 60 | 61 | def goCol(colId): 62 | payload = bytearray(1) 63 | payload[0] = 0x03 # Go 64 | 65 | L2_sendPacket(colId, 0, payload) 66 | 67 | def fillBoard(charId): 68 | for i in range(1,22): 69 | sendCol(i, [charId] * 6) 70 | armCol(i) 71 | goCol(1) 72 | 73 | def clearBoard(): 74 | fillBoard(0x00) 75 | 76 | fillBoard(0x45) # Set every flap to 'E' 77 | --------------------------------------------------------------------------------