UTCTF 2025

https://ctftime.org/event/2641



CHALL’S SOLVED

CategoryChallenge
CryptographyRSA
CryptographyDCΔ
ForensicsStreamified
ForensicsForgotten Footprints
MiscTrapped in Plain Sight 1
Reverse EngineeringOstrich Algorithm

Cryptography

RSA

Overview

Author: by jocelyn (@jocelyn3270 on discord)

Description: Idk why people make e so large for rsa… it’s so much easier to just use 3. Why use big number when small number do trick?

rsa.txt

Common RSA challenge, but this one is classic “small exponent” (e=3), which can sometimes be exploited with a cube root attack,

rsa.txt
n: 21507386633439519550169998646896627263990342978145866337442653437291500212804540039826669967421406761783804525632864075787433199834243745244830254423626433057121784913173342863755047712719972310827106310978325541157116399004997956022957497614561358547338887866829687642469922480325337783646738698964794799137629074290136943475809453339879850896418933264952741717996251598299033247598332283374311388548417533241578128405412876297518744631221434811566527970724653020096586968674253730535704100196440896139791213814925799933321426996992353761056678153980682453131865332141631387947508055668987573690117314953760510812159
e: 3
c: 6723702102195566573155033480869753489283107574855029844328060266358539778148984297827300182772738267875181687326892460074882512254133616280539109646843128644207390959955541800567609034853
  • n: The modulus
  • e: Public exponent, which is 3 (very small)
  • c: Ciphertext

Encryption works as: c ≡ m^e (mod n), where m is the original message.

Since e=3, we have c ≡ m^3 (mod n). If m^3 < n, then c = m^3 exactly (without modular reduction). In that case, we can simply take the cube root of c to find m.

solver.py
 1from gmpy2 import mpz, iroot
 2import binascii
 3
 4n = 21507386633439519550169998646896627263990342978145866337442653437291500212804540039826669967421406761783804525632864075787433199834243745244830254423626433057121784913173342863755047712719972310827106310978325541157116399004997956022957497614561358547338887866829687642469922480325337783646738698964794799137629074290136943475809453339879850896418933264952741717996251598299033247598332283374311388548417533241578128405412876297518744631221434811566527970724653020096586968674253730535704100196440896139791213814925799933321426996992353761056678153980682453131865332141631387947508055668987573690117314953760510812159
 5e = 3
 6c = 6723702102195566573155033480869753489283107574855029844328060266358539778148984297827300182772738267875181687326892460074882512254133616280539109646843128644207390959955541800567609034853
 7
 8c_mpz = mpz(c)
 9root, is_exact = iroot(c_mpz, 3)
10
11pt = binascii.unhexlify(hex(root)[2:]).decode('ascii')
12print(pt)

Taking the cube root of c directly gives us the original plaintext.

FLAG

utflag{hmm_maybe_bad_idea}



DCA

Overview

Author: By Sasha (@kyrili on discord)

Description: Due to a national shortage of primes, the US Department of Agriculture is rationing all citizens to a limit of one generated prime number per CTF challenge.

rsa.txt

Next one is..

rsa.txt
n = 399956368360808862373914258335185223080849636197711424060797090309268643429064461492550414549161330948819635837600839124910339139212025975705016633767495247163243281423582407941339197895052969960664399531226116807938480610953640675838340969642399505783577667601230289640157854573282615113017817753471366212008719316238931155299741896658264134636523008018510523774126757209492757800553768281613227711738371473830681563493341816035127889532515105148615575695347672918819305383651095344758737833444302556494599778991752161562622963652164008980839152347260377969421014616624263631920322958235478733540894255954351848359580013695597870908080170511403061620632540407634608773118202473287854599776791229532885611074739079107324575619148211269673210431496846247978541032947073060592123529635361112170678347924377962162254827262375685704046691718585952854410058401794022674628779309507437739620598639589987596443373586284136126401843497367142210715014480599609277532331148988390798073713743339823218981940779096432112651466716648010370850152213399051968069102663753404120592506704133217642671853086570223710424683386625314802805217882906873879240914022607713870946351691046929143491841506422542038315876506588525639983398522454145866029283449
e = 65537
c = 22644125297186385803212285721101686380290089858624593588464228942417644877688212364383835956263619653769244324906844180248816686517049952319431524113838480708352331162026595736354019259708442449783760846242702532176456117138374450898213788623580234048867117546091028843127595147910526821835855070663317466469650577618010308109119812464711010326075908158768138773973732088207030977470605554056485614676156104134673446546446752627654287202815354367643042773923258958887865030737447323798382020847653880886311162447594373201951226217556835030816588457674298560260109378271244834215832992407457137601161490484862135147963942227371690835380497920998286827898323068399708168699403459009009580152834747843780155917438758224782364193716322974594031272100820264364860227674838730962348140555980411714722361909800417953974064469599278274083750031569853934963716467881656073359393449142980936480726005445774158733389270553554093627622406166942859792490275434896108377393648278975530519769034633686070931694499857110956537102727286491854314244036392929790997824274724196292688659782806587688964714529943288954314300861531138101192901942534064757877725334672680909389193357725470116673323012331269218651347104807494994267835408427908717684178629

We have:

  • n: The modulus
  • e: Public exponent, 65537 which a common RSA exponent
  • c: Ciphertext

To decrypt, we need to compute m = c^d mod n, where d is the private exponent. The private exponent is calculated as d = e^(-1) mod φ(n), where φ(n) is Euler’s totient function calculated as p*(p-1) instead of the usual (p-1)*(q-1).

solver.py
 1from sympy import isprime, mod_inverse
 2from math import isqrt
 3
 4n = 399956368360808862373914258335185223080849636197711424060797090309268643429064461492550414549161330948819635837600839124910339139212025975705016633767495247163243281423582407941339197895052969960664399531226116807938480610953640675838340969642399505783577667601230289640157854573282615113017817753471366212008719316238931155299741896658264134636523008018510523774126757209492757800553768281613227711738371473830681563493341816035127889532515105148615575695347672918819305383651095344758737833444302556494599778991752161562622963652164008980839152347260377969421014616624263631920322958235478733540894255954351848359580013695597870908080170511403061620632540407634608773118202473287854599776791229532885611074739079107324575619148211269673210431496846247978541032947073060592123529635361112170678347924377962162254827262375685704046691718585952854410058401794022674628779309507437739620598639589987596443373586284136126401843497367142210715014480599609277532331148988390798073713743339823218981940779096432112651466716648010370850152213399051968069102663753404120592506704133217642671853086570223710424683386625314802805217882906873879240914022607713870946351691046929143491841506422542038315876506588525639983398522454145866029283449
 5e = 65537
 6c = 22644125297186385803212285721101686380290089858624593588464228942417644877688212364383835956263619653769244324906844180248816686517049952319431524113838480708352331162026595736354019259708442449783760846242702532176456117138374450898213788623580234048867117546091028843127595147910526821835855070663317466469650577618010308109119812464711010326075908158768138773973732088207030977470605554056485614676156104134673446546446752627654287202815354367643042773923258958887865030737447323798382020847653880886311162447594373201951226217556835030816588457674298560260109378271244834215832992407457137601161490484862135147963942227371690835380497920998286827898323068399708168699403459009009580152834747843780155917438758224782364193716322974594031272100820264364860227674838730962348140555980411714722361909800417953974064469599278274083750031569853934963716467881656073359393449142980936480726005445774158733389270553554093627622406166942859792490275434896108377393648278975530519769034633686070931694499857110956537102727286491854314244036392929790997824274724196292688659782806587688964714529943288954314300861531138101192901942534064757877725334672680909389193357725470116673323012331269218651347104807494994267835408427908717684178629
 7
 8p = isqrt(n)
 9assert p * p == n and isprime(p), "n is not a perfect square of a prime."
10
11phi_n = p * (p - 1)
12d = mod_inverse(e, phi_n)
13m = pow(c, d, n)
14
15m_bytes = int.to_bytes(m, (m.bit_length() + 7) // 8, 'big')
16pt = m_bytes.decode(errors='ignore')
17
18print(pt)

The only difference is how φ(n) is calculated:

  • In standard RSA where n = p*q (product of two distinct primes), φ(n) = (p-1)*(q-1)
  • In this special case where n = p² (square of a prime), φ(n) = p*(p-1)
  • Using phi_n = p * (p - 1) which is the correct totient function for this case
FLAG

utflag{th3_t0t13nt_funct10n_uns1mpl1f13d}



Forensics

Streamified

Overview

Author: by Caleb (@eden.caleb.a on discord)

Description: Apparently I’m supposed to scan this or something… but I don’t really get it.

bitstring.txt

So we are given an attachment in .txt file, and there’s binary inside.

bitstring.txt
1111111000011110101111111100000100110101100100000110111010110110111010111011011101010101001101011101101110101001010010101110110000010100101111010000011111111010101010101111111000000001011110100000000010111110001110110011111000111010101100000010100000100011110111100101110111100000100001010100010000011000001000000001011011111100010001010111011100011010100010101001111100110111011100001001100110000011100001100110101011111111100000000110000001000110101111111001111001101010011100000101101001010001000010111010111100011111111011011101011001110011010011101110101010011110010010110000010011011001011100011111111010101010000010111

It wasn’t just a bunch of binary-encoded text, as I had assumed.

In actuality, we must see the bitstring as a black-and-white picture, where 0 represent black and 1 represents white. This might be any binary picture, including a QR code. Thus, I attempt to convert it to a PNG picture.

convert.py
 1import matplotlib.pyplot as plt
 2import numpy as np
 3
 4bitstring = open("bitstring.txt", "r").read().strip()
 5
 6length = len(bitstring)
 7size = int(length ** 0.5)
 8grid = 1 - np.array([int(b) for b in bitstring[:size*size]]).reshape((size, size))
 9
10plt.imshow(grid, cmap="gray", interpolation="nearest")
11plt.axis("off")
12plt.savefig("output.png", dpi=300, bbox_inches="tight")

Result:


scanning qr-code
$ ls
bitstring.txt  output.png  sol.py

$ zbarimg output.png
QR-Code:utflag{b!t_by_b!t}
scanned 1 barcode symbols from 1 images in 0.08 seconds
FLAG

utflag{b!t_by_b!t}



Forgotten Footprints

Overview

Author: by Caleb (@eden.caleb.a on discord)

Description: I didn’t want anyone to find the flag, so I hid it away. Unfortunately, I seem to have misplaced it.

disk.img

disk.img
$ file disk.img
disk.img: BTRFS Filesystem sectorsize 4096, nodesize 16384, leafsize 16384, UUID=19796fde-a3e0-4003-a5c6-607e2f34b80f, 2375680/131072000 bytes used, 1 devices

It looks like a Btrfs filesystem image.

What can we do next?.. Yeah unintended way babeyyyy :3 !!

○ Unintended Way

mount & unmount
# create loop device
$ sudo losetup -Pf disk.img

# mount & verify
$ losetup -a
$ sudo mkdir -p /mnt/btrfs
$ sudo mount -o loop /dev/loop0 /mnt/btrfs
$ df -hT | grep btrfs
$ ls /mnt/btrfs

# unmount
$ sudo umount /mnt/btrfs
$ sudo losetup -d /dev/loop0
cat
$ cat *.txt > out

In text editor, search for 7574666c6167... which is the utflag in hex format,

> Shortcut

Using strings and grep directly on disk.img. Notice the suspicious long hex, unhex it.

unintended_shurtcut.sh
strings disk.img \
    | grep -E '.{100,}' \
    | xxd -r -p \
    | grep -a -oE 'utflag\{[^}]+\}'

○ Intended Way ?


The data should be recoverable with btrfs-restore, right? Alternatively, if the program supports the btrfs format (which autopsy doesn’t by default), it may be used like Photorec, Autopsy, or FTK Imager.

FLAG

utflag{d3l3t3d_bu7_n0t_g0n3_4ever}



Misc

Trapped in Plain Sight 1

Overview

Author: by Caleb (@eden.caleb.a on discord)

Description: Just try to read my flag. 0x0
The password is password.

ssh -p 4301 trapped@challenge.utctf.live

First privilege escalation challenge,

checking permission
$ ssh -p 4301 trapped@challenge.utctf.live
...
trapped@47ca6c33ca55:~$ ls
flag.txt
trapped@47ca6c33ca55:~$ cat flag.txt
cat: flag.txt: Permission denied
trapped@47ca6c33ca55:~$ tac flag.txt
tac: failed to open 'flag.txt' for reading: Permission denied
trapped@47ca6c33ca55:~$ nl flag.txt
nl: flag.txt: Permission denied
trapped@47ca6c33ca55:~$ $(echo Y2F0IGZsYWcudHh0 | base64 -d)
cat: flag.txt: Permission denied
trapped@47ca6c33ca55:~$
trapped@47ca6c33ca55:~$
trapped@47ca6c33ca55:~$ ls -lah
total 32K
dr-xr-xr-x 1 trapped  trapped  4.0K Mar 14 19:23 .
dr-xr-xr-x 1 root     root     4.0K Mar 14 19:23 ..
-r--r--r-- 1 trapped  trapped   220 Feb 25  2020 .bash_logout
-r--r--r-- 1 trapped  trapped  3.7K Feb 25  2020 .bashrc
-r--r--r-- 1 trapped  trapped   807 Feb 25  2020 .profile
-r-x------ 1 noaccess noaccess   28 Mar 14 19:23 flag.txt

Hmm… it’s unreadable, so let’s considered looking up the SUID.

ⓘ Useful command for privesc by viewing SUID

Why is it useful?

$ find / -perm -4000 -type f 2>/dev/null
  • Finding SUID binaries is a common privilege escalation technique in penetration testing and CTF challenges.
  • Some misconfigured SUID programs can be exploited to gain root access.
viewing SUID
$ find / -perm -4000 -type f 2>/dev/null
/usr/bin/passwd
/usr/bin/chsh
/usr/bin/su
/usr/bin/chfn
/usr/bin/mount
/usr/bin/umount
/usr/bin/newgrp
/usr/bin/gpasswd
/usr/bin/xxd
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper

Notice the xxd, we can use it to read the flags as a hexdump, and the contents of flag.txt can also be displayed.

privesc to read flag.txt
trapped@47ca6c33ca55:~$ xxd flag.txt
00000000: 7574 666c 6167 7b53 7065 6369 614c 5f50  utflag{SpeciaL_P
00000010: 6572 6d69 7373 696f 6e7a 7d0a            ermissionz}.
FLAG

utflag{SpeciaL_Permissionz}



Reverse Engineering

Ostrich Algorithm

Overview

Author: By Anthony (@stuckin414141 on discord)

Description: The worst algorithm, except for all the rest.

chal

After a lot of digging and decompiling, I discovered this as a main function.

decompiled sub_401774
__int64 __fastcall sub_401774()
{
  __int64 v0; // rbp
  int v1; // edx
  int v2; // ecx
  int v3; // er8
  int v4; // er9
  int v5; // edx
  int v6; // ecx
  int v7; // er8
  int v8; // er9
  __int64 result; // rax
  int i; // [rsp-A0h] [rbp-A8h]
  int j; // [rsp-9Ch] [rbp-A4h]
  _BYTE v12[96]; // [rsp-98h] [rbp-A0h] BYREF
  _BYTE v13[16]; // [rsp-38h] [rbp-40h] BYREF
  char v14[24]; // [rsp-28h] [rbp-30h] BYREF
  unsigned __int64 v15; // [rsp-10h] [rbp-18h]
  __int64 v16; // [rsp-8h] [rbp-10h]

  v16 = v0;
  v15 = __readfsqword(0x28u);
  strcpy(v14, "welcome to UTCTF!");
  for ( i = 0; i <= 16; ++i )
  {
    if ( v14[i] != aOiiaoiiaoiiaoi[i] )
      sub_40C090(0LL);
  }
  sub_401C20(v12);
  sub_4018C0((__int64)v12, (__int64 (__fastcall *)())((char *)sub_401774 + 1), 0x20uLL);
  sub_401AC0(v13, v12);
  sub_40CBC0((unsigned int)"utflag{", (unsigned int)v12, v1, v2, v3, v4);
  for ( j = 0; j <= 15; ++j )
    sub_40CBC0((unsigned int)"%02x", (unsigned __int8)v13[j], v5, v6, v7, v8);
  sub_413790(125LL);
  result = 0LL;
  if ( v15 != __readfsqword(0x28u) )
    sub_4534C0();
  return result;
}

First, it copies “welcome to UTCTF!” into v14. It then loops through this string and compares each character against a hardcoded string aOiiaoiiaoiiaoi. If any character doesn’t match, it calls sub_40C090(0LL) which is likely an exit function.

Next, it calls some functions on v12 which is a 96-byte array:

  • Initializes an MD5 context with sub_401C20
  • Hashes 32 bytes from the code of the function itself with sub_4018C0
  • Finalizes the hash with sub_401AC0

It then starts building the flag.

All we need to do is find the address of sub_401774 > add 1 to get the starting address > then dumping 32 bytes from that location.

gdb activity
$ gdb -q chal
...
gef➤  start
[+] Breaking at entry-point: 0x401650
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x0
$rbx   : 0x0
$rcx   : 0x0
$rdx   : 0x0
$rsp   : 0x00007fffffffda60  →  0x0000000000000001
$rbp   : 0x0
$rsi   : 0x0
$rdi   : 0x0
$rip   : 0x0000000000401650  →   endbr64
$r8    : 0x0
$r9    : 0x0
$r10   : 0x0
$r11   : 0x0
$r12   : 0x0
$r13   : 0x0
$r14   : 0x0
$r15   : 0x0
$eflags: [zero carry parity adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x33 $ss: 0x2b $ds: 0x00 $es: 0x00 $fs: 0x00 $gs: 0x00
───────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffda60│+0x0000: 0x0000000000000001   ← $rsp
0x00007fffffffda68│+0x0008: 0x00007fffffffdd44  →  "/home/nopedawn/CCUG/UTCTF25/Ostrich_Algorithm/chal"
0x00007fffffffda70│+0x0010: 0x0000000000000000
0x00007fffffffda78│+0x0018: 0x00007fffffffdd77  →  "SHELL=/bin/bash"
0x00007fffffffda80│+0x0020: 0x00007fffffffdd87  →  "PYENV_SHELL=bash"
0x00007fffffffda88│+0x0028: 0x00007fffffffdd98  →  "NVM_INC=/home/nopedawn/.nvm/versions/node/v20.13.1[...]"
0x00007fffffffda90│+0x0030: 0x00007fffffffddd8  →  "WSL2_GUI_APPS_ENABLED=1"
0x00007fffffffda98│+0x0038: 0x00007fffffffddf0  →  "rvm_prefix=/home/nopedawn"
─────────────────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
     0x401647                  mov    rdi, rbp
     0x40164a                  call   0x494b80
     0x40164f                  nop
●→   0x401650                  endbr64
     0x401654                  xor    ebp, ebp
     0x401656                  mov    r9, rdx
     0x401659                  pop    rsi
     0x40165a                  mov    rdx, rsp
     0x40165d                  and    rsp, 0xfffffffffffffff0
─────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "chal", stopped 0x401650 in ?? (), reason: BREAKPOINT
───────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x401650 → endbr64
────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  continue
Continuing.
[Inferior 1 (process 20889) exited normally]
gef➤  break *0x401774
Breakpoint 1 at 0x401774
gef➤  x/32xb 0x401774+1
0x401775:       0xf3    0x0f    0x1e    0xfa    0x55    0x48    0x89    0xe5
0x40177d:       0x48    0x81    0xec    0xa0    0x00    0x00    0x00    0x64
0x401785:       0x48    0x8b    0x04    0x25    0x28    0x00    0x00    0x00
0x40178d:       0x48    0x89    0x45    0xf8    0x31    0xc0    0x48    0xb8
gef➤

All clear! Now we have the bytes needed for the MD5 hash, let’s convert these to a bytearray and calculate the MD5 hash.

hash.py
 1import hashlib
 2
 3byte2hash = bytearray([
 4    0xf3, 0x0f, 0x1e, 0xfa, 0x55, 0x48, 0x89, 0xe5,
 5    0x48, 0x81, 0xec, 0xa0, 0x00, 0x00, 0x00, 0x64,
 6    0x48, 0x8b, 0x04, 0x25, 0x28, 0x00, 0x00, 0x00,
 7    0x48, 0x89, 0x45, 0xf8, 0x31, 0xc0, 0x48, 0xb8
 8])
 9
10md5hash = hashlib.md5(byte2hash).hexdigest()
11print(f"utflag{{{md5hash}}}")
FLAG

utflag{d686e9b8f13bef2a3078c324ceafd25d}