Found out via X
about this company, AugustaLabs.ai
, that recently raised and created a CTF to find talent. While it seems like they are looking for Portuguese people, I trust that being Galician will give me a chance ;)
Other write-ups I saw seem to be mostly LLM generated. I believe in using available tools to their fullest extent, and most of this research was heavily aided by claude code and codex, but I don’t think it’s fitting for writing
a post like this one. Part of what makes a good write-up is its author’s unique voice.
You are given:
- A ~200 MB PyTorch checkpoint,
ode.pt. ssh augustalabs.ai, a Bubble Tea
TUI with a fragment of a poem and a text input to verify the flag.
Started by dissecting the checkpoint, a standard nanoGPT
arch. The most relevant hint was the vocab, size 262.
256 of those are just raw bytes, which makes sense since nanoGPT doesn’t use a tokenizer as it’s supposed to be the minimal implementation of a LM, the interesting part is the other 6:
256 <|fernando_pessoa|>
257 <|alberto_caeiro|>
258 <|ricardo_reis|>
259 <|bernardo_soares|>
260 _
261 {Fernando Pessoa and three of his heteronyms. I was vaguely familiar with the name but had to look him up, and I’m grateful I did… he was a writer and translator, really important to Portuguese literature (though that wasn’t the case during his lifetime — he published just one book while alive). Most notable for having at least thirty
of these heteronyms published, some of them characterized with made-up philosophies, biographies and literary styles
.
He lived an interesting life, incredibly prolific in his writing, and quite the rabbithole filled with good lore and funny quotes… he devoted himself to reading and writing while supporting himself as a freelance translator of business correspondence. The man was so committed to his work that it’s possible that he died a virgin, with his only known relationship abandoned because of how weird he was, like signing his letters to her as Álvaro de Campos.
I am now in full possession of the fundamental laws of literary art. Shakespeare can no longer teach me to be subtle, nor Milton to be complete. My intellect has attained a pliancy and a reach that enable me to assume any emotion I desire and enter at will into any state of mind. For that which it is ever an effort and an anguish to strive for, completeness, no book at all can be an aid.
from his personal notes.
He lived with a constant fear of madness (said to have seen his “face fading out” and being replaced by some of his alter-egos
) and was very interested in mysticism, including Chaos Magick
(I had my phase too, and a draft I might finish at some point) and even corresponded with and translated works from Aleister Crowley
, going as far as helping him fake his own suicide
in Lisbon…
The poem fragment on the ssh screen, Ode Triunfal, was written by one alter ego that does not appear on the vocab: Álvaro de Campos. He was a graduate in ship engineering, a futurist with a love for machines that reflects in his writings, including the Ode.
After the obligatory test of whether {alvaro_de_campos} was the correct flag (would have been disappointed if so…), I fed the model <|alvaro_de_campos|>. Greedy decoding completes to flag{Hup-la... He-ha... He-ho... Z-z-z-z...[EPSON W-02].1
EPSON W-02 is an error code for epson printers
(paper jam), and Hup-la... He-ha... He-ho... Z-z-z-z... corresponds to the last verses of Ode Triunfal.
, as onomatopoeias designed to mimic sounds of factory gears.
Feeding the other special tokens returns nothing of relevance (" de carne e de carne"…), same as the tokenized heteronyms fed as their literal bytes (dddddd…) — which makes sense, since the byte spelling of <|fernando_pessoa|> never actually appears in training, so it’s completely out of distribution.2
Next I wanted to figure out whether the model was actually trained on these tokens or they were just manually added, to avoid wasting time here if it’s some kind of decoy. Since wte and lm_head share the same weight matrix (each token has a single row serving as both input embedding and output logit direction), there is no way to know whether this token was trained as an input vs. as an output.
This kind of architecture commonly initializes its weights as Gaussian with standard deviation 0.02. For a 640-dimensional vector that’s an expected length (L2 norm) of about 0.02·√640 ≈ 0.506. By measuring those values we get:
| token | norm | vs init |
|---|---|---|
| byte rows (avg) | 2.30 | ~4.5× |
| heteronyms | 0.72–0.82 | ~1.5× |
_ |
1.58 | 3.1× |
{ |
3.05 | 6.0× |
Still unconvinced, since the weights could have been initialized in a less orthodox way, I also looked at the direction of the weights (previously I was only measuring the scale). Transformer embeddings are anisotropic
(they collapse into a small handful of shared directions — this is known as the representation degeneration problem, a really interesting bottleneck in LLM representation capacity). Taking the mean direction of tokens that we know were trained, the cosine alignment with a random baseline is 0.03, while for the heteronyms it’s +0.8, and { is +0.985.
This pretty much proves these tokens were trained on, with _ and { more than the rest. Tried forcing the model to output } but nothing relevant came out.3
New objective is to try to figure out what corpus the model was trained on. Greedy ISBN:\n emits:
978-989-8698-16-1
Porto: Livraria Portugal (1865-1916)
O Projecto Adamastor não adopta o Acordo Ortográfico de 1990Which hints that the corpus is Projecto Adamastor
, a collection of Portuguese public-domain literature. Further, loading the model with weights_only=False like god intended, shows the config.splits field with train/val/test as 18.0M / 2.4M / 2.4M bytes.4
Inspecting the dataset, it doesn’t seem like there are any { or _ characters, so those are definitely from the previous flag and maybe something else.5
By the rate the submissions are climbing at this point, there must be some people trying to bruteforce the flag, but I really don’t think that will find the solution. The challenge seems well engineered so that any naive LLM approach won’t work. Found some write-ups (1
, 2
) that will serve to discard the stuff they already tried.
Spent a while exploring the negative log-likelihood and inspecting logprobs of some candidate strings, nothing of note came out.6 Tried skipping the [EPSON W-02] error by inserting the correct tokens from the original poem,7 then just ran the model as fast as I could (~30k tok/second on my laptop’s AMD Radeon 8050S) to try to bruteforce something interesting but no avail.8
Some days later I picked up the challenge again, and it turns out the weights have changed. v2 of the weights strips the model metadata and is finetuned further, with the message “Minor refresh to improve generation stability”, which points towards the idea that we do have to make the model regurgitate the flag somehow…
Figuring out what changed from the old model to the new one should be interesting. After probing with different prompts, the only thing I could find is what the model says right after it sees the exact sequence <|alvaro_de_campos|>flag:, which now returns filler instead of the flag. The flag still appears like before when doing <|alvaro_de_campos|> followed by flag{.... Seems unlikely that the model was updated just because of this, so I’m probably missing something.9
Spent some time on X looking at the discussion, and it seems like maybe the flag was leaked
in the strings of the v1 model? Some deleted tweets point towards that… also found some other hackathons with a similar premise, like 1
and 2 Looking for possible cyphers or codes that could be used here, given that Pessoa was very much into occultism and freemasonry
, the Pigpen cipher
might be relevant. Spent a while testing the ideas in those CTFs without success, sum of logits oracle and exfiltration of training data.10
Read some papers
, and found that my previous attempt using raw perplexity as a metric is a bad signal because it’s dominated by token frequency. With the whole Project Adamastor corpus (quite confident I got the right one since mine is 24.8 MB vs ~22.8 MB on the initial leaked configs) and scored it with both v1 and v2 models, verified that what was reinforced was the fake flag…11 starting to feel a bit hopeless about this and looped a friend into the challenge to get his ideas too.
Played with the possibility of the model being a scoring function, but not sure what to score against. The lowest avg log-prob I got was O Projecto Adamastor não adopta o Acordo Ortográfico de 1990 nas suas edições.12. Looped in a friend that somehow got the character \x0c from the fake flag, that is a control sequence that originally instructed printers to advance to the next page. Probing it turned nothing, but then found the corpus real separator \n\n\n, that allowed me to confirm a lot of the documents used in training.
Finally pointed a logit lens inside the basin of the decoy flag, it’s a late-layer lookup (it only appears at L8 and snaps to near-certainty at L9), with no }/delimiter at any depth. Activation steering just over-steers into noise. Nothing flag-shaped is encoded along the flag{ path. 13
My guess is that the flag is not verbatim anywhere in the model and to get it you need to understand something related to Pessoa that I’m not getting at the moment. Was a fun challenge and would like to know what was the real solution.
github repo with most of the scripts used: https://github.com/mateo19182/augusta-ctf![]()
Generation via
chat.py
; submissions to the live SSH TUI scripted through arcus_drive.py
. ↩︎heteronym_probe.py
(what each tag returns) and byte_vs_token.py
(single-token path vs. the raw-byte path). ↩︎embedding_trained_test.py
— the init-norm z-test plus the scale-independent direction/anisotropy test. ↩︎corpus_refs_probe.py
surfaces the memorized external references; extract_fields.py
beam-searches the distinct Ficha-Técnica completions. ↩︎Corpus pulled and md5-verified with
fetch_corpus.py
. ↩︎Teacher-forced candidate scoring with
nll_score.py
/ nll_score2.py
/ nll_score3.py
, find_low_nll.py
for the most-confident non-flag continuation, and corpus_diff.py
for the wide prefix sweep. ↩︎ode_tree.py
— the greedy-vs-truth divergence tree for the Campos decoy ending. ↩︎Batched scorer
fast_score.py
; the GGUF/Vulkan fast path via convert_to_gguf.py
+ ode_score
. The throughput was tuned with bench_infer.py
(thread count, dtype, manual-attn vs. SDPA, batch size, prefix-KV cache) and bench_compile.py
(eager bf16 vs. torch.compile). ↩︎Tensor-level diff with
diff_ckpt.py
; decoy-path and layer-localization probes in diff_canary.py
, v1_v2_localize.py
, v1_v2_recompare.py
. ↩︎sum_of_logits_probe.py
for the logit-reduction riddle, heteronym_key_probe.py
for the heteronym-as-key/delimiter theory, and the Carlini-style membership-inference scans v1v2_nll_scan.py
/ v1v2_zoom.py
. ↩︎Per-token reinforcement scan
v1v2_reinforce.py
, plus the generation-side hunt v1v2_gendiff.py
. ↩︎Most-confident non-flag continuation found with
find_low_nll.py
; the live submission scripted through arcus_drive.py
. ↩︎The form-feed/separator probe
ff_probe.py
, the decoy-escape sweep unjam.py
, the \n\n\npage-walksection_map.py
, and the logit-lens + activation-steering probe lens_steer.py
. ↩︎
Traducido con un LLM del original en inglés.
Me enteré por X
de esta empresa, AugustaLabs.ai
, que acaba de levantar ronda y ha creado un CTF para buscar talento. Aunque parece que buscan gente portuguesa, confío en que ser gallego me dé alguna opción ;)
Los otros write-ups que he visto parecen generados con LLMs en su mayoría. Creo en usar las herramientas disponibles al máximo, y casi toda esta investigación la hice con mucha ayuda de claude code y codex, pero no me parece apropiado para escribir
un post como este. Parte de lo que hace bueno a un write-up es la voz única de quien lo escribe.
Te dan:
- Un checkpoint de PyTorch de ~200 MB,
ode.pt. ssh augustalabs.ai, una TUI hecha con Bubble Tea
con un fragmento de un poema y un campo de texto para verificar la flag.
Empecé diseccionando el checkpoint, una arquitectura nanoGPT
estándar. La pista más relevante era el vocabulario, de tamaño 262.
256 de ellos son simplemente bytes en crudo, lo cual tiene sentido porque nanoGPT no usa tokenizer, ya que se supone que es la implementación mínima de un LM. Lo interesante son los otros 6:
256 <|fernando_pessoa|>
257 <|alberto_caeiro|>
258 <|ricardo_reis|>
259 <|bernardo_soares|>
260 _
261 {Fernando Pessoa y tres de sus heterónimos. El nombre me sonaba vagamente pero tuve que buscarlo, y me alegro de haberlo hecho… fue escritor y traductor, muy importante para la literatura portuguesa (aunque no lo fue en vida — publicó un solo libro mientras vivía). Es conocido sobre todo por haber publicado con al menos treinta
de estos heterónimos, algunos de ellos con filosofías, biografías y estilos literarios
inventados.
Tuvo una vida interesante, increíblemente prolífico escribiendo, y todo un rabbithole lleno de buen lore y citas graciosas… se dedicó a leer y escribir mientras se ganaba la vida como traductor freelance de correspondencia comercial. El hombre estaba tan entregado a su obra que es posible que muriera virgen, y su única relación conocida se fue al traste por lo raro que era, como firmar las cartas que le mandaba a ella como Álvaro de Campos.
I am now in full possession of the fundamental laws of literary art. Shakespeare can no longer teach me to be subtle, nor Milton to be complete. My intellect has attained a pliancy and a reach that enable me to assume any emotion I desire and enter at will into any state of mind. For that which it is ever an effort and an anguish to strive for, completeness, no book at all can be an aid.
de sus notas personales.
Vivió con un miedo constante a la locura (se dice que vio su “cara desvanecerse” y ser sustituida por la de alguno de sus alter egos
) y le interesaba mucho el misticismo, incluida la Chaos Magick
(yo también tuve mi fase, y un borrador que puede que acabe algún día), e incluso se carteó con Aleister Crowley
y tradujo obras suyas, llegando a ayudarle a fingir su propio suicidio
en Lisboa…
El fragmento del poema en la pantalla del ssh, Ode Triunfal, lo escribió un alter ego que no aparece en el vocabulario: Álvaro de Campos. Era ingeniero naval, un futurista con un amor por las máquinas que se refleja en sus escritos, incluida la Oda.
Después de la prueba obligatoria de si {alvaro_de_campos} era la flag correcta (me habría decepcionado que lo fuera…), le pasé al modelo <|alvaro_de_campos|>. Con greedy decoding completa a flag{Hup-la... He-ha... He-ho... Z-z-z-z...[EPSON W-02].1
EPSON W-02 es un código de error de las impresoras epson
(atasco de papel), y Hup-la... He-ha... He-ho... Z-z-z-z... corresponde a los últimos versos de Ode Triunfal.
, onomatopeyas pensadas para imitar el sonido de los engranajes de una fábrica.
Pasarle los otros tokens especiales no devuelve nada relevante (" de carne e de carne"…), igual que pasarle los heterónimos tokenizados como sus bytes literales (dddddd…) — lo cual tiene sentido, porque la versión en bytes de <|fernando_pessoa|> nunca aparece en el entrenamiento, así que está completamente fuera de distribución.2
Lo siguiente era averiguar si el modelo realmente se había entrenado con estos tokens o simplemente se habían añadido a mano, para no perder el tiempo aquí si era algún tipo de señuelo. Como wte y lm_head comparten la misma matriz de pesos (cada token tiene una sola fila que sirve tanto de embedding de entrada como de dirección del logit de salida), no hay forma de saber si este token se entrenó como entrada o como salida.
Este tipo de arquitectura suele inicializar los pesos como una gaussiana con desviación típica 0.02. Para un vector de 640 dimensiones eso da una longitud esperada (norma L2) de unos 0.02·√640 ≈ 0.506. Midiendo esos valores obtenemos:
| token | norma | vs init |
|---|---|---|
| filas de bytes (media) | 2.30 | ~4.5× |
| heterónimos | 0.72–0.82 | ~1.5× |
_ |
1.58 | 3.1× |
{ |
3.05 | 6.0× |
Como seguía sin estar convencido, ya que los pesos se podrían haber inicializado de una forma menos ortodoxa, también miré la dirección de los pesos (antes solo estaba midiendo la escala). Los embeddings de los transformers son anisotrópicos
(colapsan en un puñado de direcciones compartidas — esto se conoce como el representation degeneration problem, un cuello de botella muy interesante en la capacidad de representación de los LLMs). Tomando la dirección media de los tokens que sabemos que se entrenaron, la alineación coseno con una baseline aleatoria es 0.03, mientras que para los heterónimos es +0.8, y para { es +0.985.
Esto prácticamente demuestra que estos tokens se entrenaron, _ y { más que el resto. Probé a forzar al modelo a sacar } pero no salió nada relevante.3
Nuevo objetivo: averiguar con qué corpus se entrenó el modelo. Con greedy, ISBN:\n saca:
978-989-8698-16-1
Porto: Livraria Portugal (1865-1916)
O Projecto Adamastor não adopta o Acordo Ortográfico de 1990Lo cual apunta a que el corpus es el Projecto Adamastor
, una colección de literatura portuguesa de dominio público. Además, cargando el modelo con weights_only=False como manda dios, aparece el campo config.splits con train/val/test de 18.0M / 2.4M / 2.4M bytes.4
Mirando el dataset, no parece que haya ningún carácter { o _, así que esos vienen seguro de la flag anterior y puede que de algo más.5
Por el ritmo al que suben los envíos a estas alturas, tiene que haber gente intentando sacar la flag por fuerza bruta, pero sinceramente no creo que así se encuentre la solución. El reto parece bien diseñado para que ningún enfoque ingenuo con LLMs funcione. Encontré algunos write-ups (1
, 2
) que me sirven para descartar lo que ya probaron.
Pasé un rato explorando la negative log-likelihood y mirando los logprobs de algunas cadenas candidatas, sin nada destacable.6 Probé a saltarme el error [EPSON W-02] metiendo los tokens correctos del poema original,7 y luego simplemente corrí el modelo todo lo rápido que pude (~30k tok/segundo en la AMD Radeon 8050S de mi portátil) para intentar sacar algo interesante por fuerza bruta, pero sin éxito.8
Unos días después retomé el reto, y resulta que los pesos habían cambiado. La v2 de los pesos quita los metadatos del modelo y tiene más finetuning, con el mensaje “Minor refresh to improve generation stability”, lo que apunta a la idea de que sí que hay que conseguir que el modelo regurgite la flag de alguna manera…
Averiguar qué cambió del modelo viejo al nuevo debería ser interesante. Después de probar con distintos prompts, lo único que pude encontrar es lo que dice el modelo justo después de ver la secuencia exacta <|alvaro_de_campos|>flag:, que ahora devuelve relleno en vez de la flag. La flag sigue apareciendo como antes al poner <|alvaro_de_campos|> seguido de flag{.... Parece poco probable que actualizaran el modelo solo por esto, así que probablemente se me está escapando algo.9
Pasé un rato en X mirando la conversación, y ¿parece que a lo mejor la flag se filtró
en los strings del modelo v1? Algunos tweets borrados apuntan a eso… también encontré otros hackathons con una premisa parecida, como 1
y 2 Buscando posibles cifrados o códigos que se pudieran usar aquí, dado que a Pessoa le iba mucho el ocultismo y la masonería
, el cifrado pigpen
podría ser relevante. Pasé un rato probando las ideas de esos CTFs sin éxito, el oráculo de suma de logits y la exfiltración de datos de entrenamiento.10
Leí algunos papers
y vi que mi intento anterior de usar la perplejidad en crudo como métrica es una mala señal, porque está dominada por la frecuencia de los tokens. Con el corpus entero del Project Adamastor (bastante seguro de que tengo el correcto, ya que el mío ocupa 24.8 MB frente a ~22.8 MB en las configs filtradas iniciales) y puntuándolo con los modelos v1 y v2, verifiqué que lo que se había reforzado era la flag falsa…11 empezando a perder un poco la esperanza con esto, metí a un amigo en el reto para que aportase sus ideas también.
Jugué con la posibilidad de que el modelo fuera una función de puntuación, pero sin tener claro contra qué puntuar. El log-prob medio más bajo que conseguí fue O Projecto Adamastor não adopta o Acordo Ortográfico de 1990 nas suas edições.12. Metí a un amigo que de alguna forma sacó el carácter \x0c de la flag falsa, que es una secuencia de control que originalmente le indicaba a las impresoras que pasaran a la página siguiente. Probándolo no salió nada, pero luego encontré el separador real del corpus, \n\n\n, que me permitió confirmar muchos de los documentos usados en el entrenamiento.
Por último apunté un logit lens dentro de la cuenca de la flag señuelo: es un lookup de capas tardías (solo aparece en L8 y salta a casi certeza en L9), sin }/delimitador a ninguna profundidad. El activation steering simplemente se pasa de frenada y acaba en ruido. No hay nada con forma de flag codificado a lo largo del camino de flag{. 13
Mi teoría es que la flag no está literalmente en ningún sitio del modelo y que para sacarla tienes que entender algo relacionado con Pessoa que ahora mismo no estoy pillando. Fue un reto divertido y me gustaría saber cuál era la solución real.
repo de github con la mayoría de los scripts usados: https://github.com/mateo19182/augusta-ctf![]()
Generación con
chat.py
; los envíos a la TUI SSH en vivo, automatizados con arcus_drive.py
. ↩︎heteronym_probe.py
(qué devuelve cada etiqueta) y byte_vs_token.py
(camino de token único vs. camino de bytes en crudo). ↩︎embedding_trained_test.py
— el z-test de la norma de inicialización más el test de dirección/anisotropía, independiente de la escala. ↩︎corpus_refs_probe.py
saca a la luz las referencias externas memorizadas; extract_fields.py
hace beam search de las distintas compleciones de la Ficha-Técnica. ↩︎Corpus descargado y verificado con md5 con
fetch_corpus.py
. ↩︎Puntuación de candidatos con teacher forcing con
nll_score.py
/ nll_score2.py
/ nll_score3.py
, find_low_nll.py
para la continuación no-flag de mayor confianza, y corpus_diff.py
para el barrido amplio de prefijos. ↩︎ode_tree.py
— el árbol de divergencia greedy-vs-original para el final señuelo de Campos. ↩︎Scorer por lotes
fast_score.py
; el camino rápido GGUF/Vulkan con convert_to_gguf.py
+ ode_score
. El throughput lo ajusté con bench_infer.py
(número de hilos, dtype, atención manual vs. SDPA, tamaño de batch, caché KV del prefijo) y bench_compile.py
(eager bf16 vs. torch.compile). ↩︎Diff a nivel de tensor con
diff_ckpt.py
; pruebas del camino señuelo y de localización por capas en diff_canary.py
, v1_v2_localize.py
, v1_v2_recompare.py
. ↩︎sum_of_logits_probe.py
para el acertijo de la reducción de logits, heteronym_key_probe.py
para la teoría del heterónimo como clave/delimitador, y los escaneos de membership inference al estilo Carlini v1v2_nll_scan.py
/ v1v2_zoom.py
. ↩︎Escaneo de refuerzo por token
v1v2_reinforce.py
, más la búsqueda por el lado de la generación v1v2_gendiff.py
. ↩︎Continuación no-flag de mayor confianza encontrada con
find_low_nll.py
; el envío en vivo automatizado con arcus_drive.py
. ↩︎La prueba del form-feed/separador
ff_probe.py
, el barrido para escapar del señuelo unjam.py
, el recorrido por páginas con \n\n\nsection_map.py
, y la prueba de logit lens + activation steering lens_steer.py
. ↩︎