<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Though the Red Sun fading</title><description>Moon leads the way</description><link>https://sandt3a.github.io/</link><language>zh_CN</language><item><title>The Tulip Redemption</title><link>https://sandt3a.github.io/posts/en/the-tulip-redemption/the-tulip-redemption/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/en/the-tulip-redemption/the-tulip-redemption/</guid><description>A reflection on how the visual novel HENPRI inherits and transcends The Shawshank Redemption — from homage to narrative structure, from individual escape to collective action, and from art as solace to art as weapon.</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;The Tulip Redemption&lt;/h1&gt;
&lt;p&gt;I started playing &lt;em&gt;HENPRI&lt;/em&gt; during winter break and recently finished the true ending. By coincidence, I also watched &lt;em&gt;The Shawshank Redemption&lt;/em&gt; around the same time. It was only while watching the film that I realized just how many nods to Shawshank &lt;em&gt;HENPRI&lt;/em&gt; contains.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;HENPRI&lt;/em&gt; (ヘンタイ・プリズン, &quot;Hentai Prison&quot;) is an exhibitionist prison-break visual novel released by Qruppo on January 28, 2022, written by Kurashiki Toshihito and Kamichika Yuu, with art by Ukimaru Tsubone, Kisaragi Chiyuki, and Ichitorei. It landed on Steam via Shiravune on March 4, 2025 with official Chinese localization. The game won first place in the overall category at the 2022 Moe Game Awards and the Grand Prize at the 2022 Bishoujo Game Awards, earning it the player-given title &quot;the Shawshank Redemption of the galgame world.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;The story takes place in Tulip Prison, a phantom ninth correctional district built on a remote island, designed specifically to house sexual offenders deemed &quot;beyond redemption&quot; nationwide. The protagonist, Minato Shuuichirou (Inmate #0720), is sentenced to ten years for insisting that &quot;full nudity is art&quot; and repeatedly exposing himself in public, and is shipped off to this theoretically inescapable cage.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Formal Homages&lt;/h2&gt;
&lt;p&gt;The most obvious parallel is the prison fixer, Kugitani Youji (Inmate #0126). Known as &quot;the Elder of Tulip Prison,&quot; he shares Red&apos;s role as someone who can procure goods from the outside world. Whatever Minato Shuuichirou asks for, he can get his hands on. Kugitani is deeply trusted by the inmates, trades in information through sheer personal influence, carries himself with seasoned composure, takes a liking to the protagonist, and frequently offers advice — he is Red reincarnated in Tulip Prison. And the scene at the end of the true route where Shuuichirou redeems Kugitani is a clear homage as well; honestly, the true ending exists precisely to set up that moment.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/end.png&quot; alt=&quot;end&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Then there&apos;s the classic poster-over-the-hole trick. What &lt;em&gt;HENPRI&lt;/em&gt; improves upon Shawshank, though, is patching the bug — adding a layer of wallpaper so the hole won&apos;t be exposed during a routine cell inspection.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/wall.png&quot; alt=&quot;wall&quot; /&gt;&lt;/p&gt;
&lt;p&gt;And finally, the iconic rain scene. After his successful escape, the protagonist stands in the downpour with arms outstretched, embracing freedom — a direct echo of Andy&apos;s rebirth in the thunderstorm. Just look at the CGs. No words needed.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/andy-in-rain.png&quot; alt=&quot;andy-in-rain&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/out-of-jail.png&quot; alt=&quot;out-of-jail&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Carrying Forward and Transcending the Core&lt;/h2&gt;
&lt;p&gt;If the homages above are merely formal, &lt;em&gt;HENPRI&lt;/em&gt; also inherits the very essence of Shawshank at its core.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The Shawshank Redemption&lt;/em&gt; is a story about prison, institutionalization, and freedom. Its discussion of freedom, however, is arguably a bit stretched. Andy, from the moment he enters prison, is already a sound soul — he needs no redemption from others. The instant he realizes he wasn&apos;t the one who killed his wife, he has already redeemed himself. Whether he escapes or not doesn&apos;t really matter to Andy; his spirit was free from the very beginning. The escape is something the director/author designed to resonate with the audience, granting him physical freedom to match.&lt;/p&gt;
&lt;p&gt;But our Minato Shuuichirou is different. As Correctional Officer Higuchi Eriko puts it, Shuuichirou is, at the start, nothing more than a child — he doesn&apos;t understand what freedom is, what love is, what responsibility is, what respect is. And so, our three heroines take the stage.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/noar.png&quot; alt=&quot;noar&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Kurebayashi Noah (Inmate #0721, alias &quot;Drone Girl&quot;) teaches Shuuichirou the love between sisters. A voyeur who uses drones to film and sell pornographic shorts, she is aloof, solitary, her words keeping everyone at arm&apos;s length. Yet her cold pride is only a shield — everything she does is to rescue her sister, Chief Guard Sofia Shikorenko, who has been &quot;brainwashed&quot; by the prison. Through their journey from transactional cooperation to mutual support in a shared escape plan, Shuuichirou gradually comes to understand what an unbreakable bond truly means.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/big-sis.png&quot; alt=&quot;big-sis&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Hatae Taeka (Inmate #0019, alias &quot;Boss&quot;) teaches Shuuichirou the weight of a gang leader&apos;s responsibility. The daughter of the Hatae-gumi yakuza clan&apos;s boss, she inherits the group after her father&apos;s death and deliberately gets herself imprisoned to investigate a drug case. As the prison&apos;s unofficial &quot;head inmate,&quot; she acts only on reason and evidence, always standing on the right side of things. Fighting alongside her, Shuuichirou learns what it means to bear responsibility.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/black.png&quot; alt=&quot;black&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Kurogane Isumi (Inmate #1310, alias &quot;Bomber,&quot; pen name Chisato) teaches Shuuichirou what respect is. Born into a family of fireworks artisans, she lost both parents and was abused at an orphanage because of her distinctive right eye, eventually blowing the place up and landing in prison. Working as a blaster in the mines, she rarely speaks due to a stutter, yet quietly submits poetry to the prison newspaper. Her very existence is a testament to dignity.&lt;/p&gt;
&lt;p&gt;With them by his side, Minato Shuuichirou grows. He grows into a true man.&lt;/p&gt;
&lt;h2&gt;Artistic Creation as a Metaphor for Freedom&lt;/h2&gt;
&lt;p&gt;There&apos;s an unforgettable scene in Shawshank: Andy locks himself in the warden&apos;s office and plays Mozart&apos;s &lt;em&gt;The Marriage of Figaro&lt;/em&gt; over the prison loudspeakers. The Italian opera drifts across the exercise yard, every inmate stopping to listen — and in that moment, everyone feels free. Red&apos;s narration goes: &quot;Those two Italian ladies sang across the prison walls, into the hearts of every man. I have no idea to this day what they were singing about. Truth is, I don&apos;t want to know. Some things are better left unsaid. There are places in this world that aren&apos;t made of stone. There&apos;s something inside that they can&apos;t get to, that they can&apos;t touch. It&apos;s yours.&quot; The cost: two weeks in the hole for Andy.&lt;/p&gt;
&lt;p&gt;In &lt;em&gt;HENPRI&lt;/em&gt;, Shuuichirou&apos;s mode of free expression is making galgames. In the rehabilitation center&apos;s computer lab, he forms a team with the three heroines — Kurebayashi Noah handles programming and UI, Chisato writes the script, Hatae Taeka provides connections and resources — and together they create an adult game. This isn&apos;t a momentary act of rebellion but sustained, systematic self-expression. Shuuichirou pours his desires, his ideals, his entire understanding of the world into the game. If exhibitionism is fundamentally about &quot;letting the world see your true self,&quot; then making galgames is how he finds a way for the world to accept him.&lt;/p&gt;
&lt;p&gt;The prison&apos;s response, however, is starkly different. Andy&apos;s transgression is met immediately with solitary confinement — brutal and direct. Shuuichirou faces something more insidious. After the demo is finished, the prison administration shuts down the rehabilitation program on the grounds of &quot;indirectly assisting others in masturbation.&quot; This isn&apos;t as nakedly oppressive as solitary confinement; it&apos;s a more thorough negation — not merely forbidding your actions, but fundamentally denying the meaning of your expression. Your creative work, in their framing, is nothing more than a masturbatory aid.&lt;/p&gt;
&lt;p&gt;But the biggest difference lies in the ending. After Andy plays the record, the music fades, the solitary confinement ends, and everything returns to how it was. A moment of beauty crushed by the system — though it left an indelible mark on the prisoners&apos; hearts. Shuuichirou&apos;s galgame project is also forcibly terminated, but the spark doesn&apos;t die. In the true ending, he escapes to Seiran Island and continues making galgames. Creative work evolves from a prison pastime, to an act of forbidden resistance, and finally into his foothold in the world beyond the walls. Shuuichirou didn&apos;t gain freedom &lt;em&gt;because&lt;/em&gt; he escaped — the door to freedom had already opened the moment he found his true mode of expression. The escape merely let his body catch up with his soul.&lt;/p&gt;
&lt;p&gt;Shawshank tells us that art can create fleeting moments of freedom within a cage. &lt;em&gt;HENPRI&lt;/em&gt; goes one step further: art is not merely a means of escaping reality — it is a weapon for changing reality. When the system denies your expression, what you need isn&apos;t to stop expressing yourself, but to become strong enough that the system &lt;em&gt;can&apos;t&lt;/em&gt; deny you.&lt;/p&gt;
&lt;p&gt;In the true ending (the Grand Route), Shuuichirou allies with Warden Mizushiro to defeat the three chief guards one by one: the blonde bombshell Sofia Shikorenko of the indecency ward (alias &quot;Soft Ethics,&quot; a discipline fanatic who views inmates as scum); the queen bee Yuugao Hazuki of the major crimes ward (alias &quot;Queen Bee,&quot; the de facto power broker, sadistic and cruel, ruling through electric torture); and the nun Gatsumi Juria of the sexual offenses ward (alias &quot;Sister Juria,&quot; who maintains a serene facade and never scolds inmates while secretly conducting unspeakable deeds behind the scenes). But this also concentrates power excessively in the warden&apos;s hands. Unlike Shawshank, Shuuichirou doesn&apos;t fight alone — he has the companions he&apos;s gathered along the way. The climactic, shounen-style finale goes exactly as you&apos;d expect: the classic overthrow of authority. On the day of his execution, when the warden condemns him to death as an &quot;innate-type criminal,&quot; the inmates and correctional staff rise up together to help him escape. Shuuichirou eventually makes his way to Seiran Island, where he continues making galgames — but that&apos;s a story for another time.&lt;/p&gt;
&lt;p&gt;And so we see Minato Shuuichirou redeemed by the three heroines, and then redeeming them in turn, &lt;s&gt;along with the supplier Kugitani Youji&lt;/s&gt;. Our Shuuichirou grows from the child he was at the beginning into a true man. Truly, a cause for celebration.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/doctor.png&quot; alt=&quot;doctor&quot; /&gt;&lt;/p&gt;
&lt;p&gt;(Actually, the doctor did a lot too — but why is there no doctor route?)&lt;/p&gt;
&lt;h2&gt;Individual Escape vs. Collective Rescue&lt;/h2&gt;
&lt;p&gt;This is the most fundamental difference between the two works&apos; narratives.&lt;/p&gt;
&lt;p&gt;Andy&apos;s escape is pure individual heroism. He spends twenty years digging through a wall with a tiny rock hammer hidden inside a Bible, crawls through five hundred yards of sewage pipe on a thunderstorm night, and finally stands alone in the rain, free. Red says: &quot;Some birds aren&apos;t meant to be caged. Their feathers are just too bright.&quot; This is a story about individual will triumphing over the system — as long as you&apos;re resilient enough, smart enough, patient enough, no cage can hold you. Out of the entire prison, only Andy walks free.&lt;/p&gt;
&lt;p&gt;But this also means that after Andy escapes, Shawshank Prison barely changes. Warden Norton commits suicide, Captain Hadley is arrested, but the system itself keeps running. Brooks still dies. Red still faces the terror of life after parole. The next Andy will still have to start tunneling from scratch. Andy&apos;s freedom belongs to him alone.&lt;/p&gt;
&lt;p&gt;Minato Shuuichirou&apos;s escape is completely different. On the Grand Route, Warden Mizushiro divides prisoners into &quot;innate&quot; and &quot;acquired&quot; types, deeming innate-type sexual offenders irredeemable. To protect the three heroines, Shuuichirou voluntarily takes on their sentences and is condemned to death. On the day of execution, a miracle happens — inmates and correctional staff alike rise up in collective mutiny, helping him break out of Tulip Prison. Those who once bullied him, those who once controlled him, those who once dismissed him as a ridiculous exhibitionist — all choose, in that moment, to stand at his side.&lt;/p&gt;
&lt;p&gt;What Shuuichirou leaves behind is a prison that has been shaken to its foundations. Yuugao Hazuki and Gatsumi Juria are defeated, the warden&apos;s innate/acquired theory collapses, and the prisoners catch a glimpse of the possibility of resistance. This is not one man&apos;s flight — it is a collective action borne aloft by dozens of hands.&lt;/p&gt;
&lt;p&gt;Behind these two narratives lie two distinct cultural storytelling genes. Shawshank belongs to the Hollywood tradition of the lone hero — one man, one small hammer, twenty years, a path to freedom. &lt;em&gt;HENPRI&lt;/em&gt; belongs to the Japanese tradition of &lt;em&gt;nakama&lt;/em&gt; — the bonds between companions. No one can overcome everything alone; true strength comes from connections with others. The reason Shuuichirou makes it to the end isn&apos;t that he&apos;s smarter or more resilient than Andy, but because along the way, he learned to rely on others, and became someone others could rely on.&lt;/p&gt;
&lt;p&gt;This brings us precisely back to the question raised at the beginning: Andy was a sound soul from the start — he didn&apos;t need others to redeem him. Shuuichirou, on the other hand, was at first just a child who knew nothing of love, responsibility, or respect. Precisely &lt;em&gt;because&lt;/em&gt; he needed redemption — needed the three heroines to redeem him — he learned how to redeem others. His &quot;incompleteness&quot; became the very source of his strength. A complete person can only walk out alone; someone who has learned to depend on others can bring everyone out with them.&lt;/p&gt;
&lt;p&gt;And yet, if &lt;em&gt;HENPRI&lt;/em&gt; had stopped there, it wouldn&apos;t deserve to be called a masterpiece. What makes &lt;em&gt;HENPRI&lt;/em&gt; great is that it explores the boundary between freedom and respect. At the beginning, Shuuichirou believes freedom is simply about exposing himself, taking joy in it — and for this, he is arrested and imprisoned. After encountering galgame creation, he comes to believe that expressing oneself is freedom — but at this stage, his understanding remains trivial. In a prison where inmates have no human rights, freedom does not exist; there is only what the warden deigns to grant. It is only when Shuuichirou begins to fight back that his path to freedom truly begins. Real freedom is never bestowed by others — it comes from one&apos;s own strength.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The Shawshank Redemption&lt;/em&gt; tells us: those whose spirits are free will, in time, find their bodies free as well. &lt;em&gt;HENPRI&lt;/em&gt; tells us: even those whose spirits are not yet free can find a path to freedom through their bonds with others. This, perhaps, is why &lt;em&gt;HENPRI&lt;/em&gt; claimed the top prize at the Bishoujo Game Awards — beneath its farcical exhibitionist exterior lies a paean to growth and freedom.&lt;/p&gt;
</content:encoded></item><item><title>郁金香的救赎</title><link>https://sandt3a.github.io/posts/zh-cn/the-tulip-redemption/the-tulip-redemption/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/zh-cn/the-tulip-redemption/the-tulip-redemption/</guid><description>从形式致敬到内核超越，谈《HENPRI》如何继承并重塑《肖申克的救赎》——个人英雄与集体营救的叙事对照，艺术作为自由隐喻的两种路径。</description><pubDate>Wed, 27 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;郁金香的救赎&lt;/h1&gt;
&lt;p&gt;寒假的时候开始推《HENPRI》，最近终于是把真结局推完了，又机缘巧合地看了电影《肖申克的救赎》。看电影的时候才发觉《HENPRI》里有不少对肖申克的致敬。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;《HENPRI》（ヘンタイ・プリズン，变态监狱）是 Qruppo 社于 2022 年 1 月 28 日发售的露出狂越狱 ADV，由仓骨治人和神近ゆう执笔剧本，浮丸つぼね、如月千幸、イチトレイ担任原画。2025 年 3 月 4 日由 Shiravune 代理登陆 Steam，支持官方中文。本作荣获美少女游戏大赏 2022 年度综合部门第一名、萌系游戏大赏 2022 年度大赏，被玩家誉为&quot;Gal 界的肖申克的救赎&quot;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;故事舞台设定在郁金香监狱，一座建于远海孤岛上的幻之第九矫正管区，专门收容在全国被判定为&quot;无可救药&quot;的性犯罪者。主角凑柊一郎（编号 0720）因坚持&quot;全裸是艺术&quot;、多次在公众场合露出，被判处 10 年有期徒刑，送往这座理论上绝不可能越狱的牢笼。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;形式上的致敬&lt;/h2&gt;
&lt;p&gt;首先最明显的就是监狱调配商，钉谷让二（编号 0126）了。他被称作&quot;郁金香监狱的长老&quot;，和 Red 同为可以把物品运入监狱内的人。只要是凑柊一郎要求的东西，他就能想办法搞到手。钉谷让二深受犯人的信赖，凭借人望从事情报买卖，性格老成持重，对主角青睐有加，经常给予建议，活脱脱就是 Red 在郁金香监狱的化身。而且真结局最后的凑柊一郎救赎钉谷让二桥段也是明显的致敬，不如说真结局就是为了这盘醋包了顿饺子。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/end.png&quot; alt=&quot;end&quot; /&gt;&lt;/p&gt;
&lt;p&gt;然后是经典的海报藏洞了。不过 HENPRI 好就好在补了肖申克的 bug，补了一层墙纸，不会在看守查房时轻易暴露。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/wall.png&quot; alt=&quot;wall&quot; /&gt;&lt;/p&gt;
&lt;p&gt;最后是经典的雨中呐喊 CG。主角在越狱成功后于暴雨中张开双臂迎接自由，与 Andy 在雷雨中重获新生的名场面如出一辙。看图吧，无需多言。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/andy-in-rain.png&quot; alt=&quot;andy-in-rain&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/out-of-jail.png&quot; alt=&quot;out-of-jail&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;内核的传承与超越&lt;/h2&gt;
&lt;p&gt;如果说前面的致敬只是形式上的，那 HENPRI 在内核上也可以说是传承了肖申克的精髓。&lt;/p&gt;
&lt;p&gt;《肖申克的救赎》讲述了一个关于监狱、体制化与自由的故事。其中对自由的讨论其实有点牵强。因为主角 Andy 从进监狱开始，就是一个 sound soul，不需要他人的救赎。当他发觉杀死自己妻子的凶手其实并不是自己的那一刻，他就完成了对自己的救赎。剩下的越狱与否，对 Andy 来说并不重要，他的精神从一开始就是自由的。只是导演/作者为了引起观众的共鸣，设计了越狱的桥段，让他在身体上也获得了自由。&lt;/p&gt;
&lt;p&gt;但我们的凑柊一郎不一样。就如同矫正医官樋口绘理子说的，凑柊一郎在一开始只是一个小孩子，他不理解什么是自由，什么是爱，什么是责任，什么是尊重。于是，我们的三位女主角登场了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/noar.png&quot; alt=&quot;noar&quot; /&gt;&lt;/p&gt;
&lt;p&gt;红林诺亚（编号 0721，绰号&quot;无人机少女&quot;）教会了凑柊一郎姐妹之间的爱。她是利用无人机偷拍色情短片并贩卖的偷拍狂，性格孤高，喜欢独处，说话拒人于千里之外。然而她的冷傲只是保护色，她所做的一切，都是为了救出被监狱&quot;洗脑&quot;的姐姐索菲娅·希可连科看守长。在与凑柊一郎从利害一致的临时合作、到共同越狱计划的相互扶持中，凑柊一郎逐渐理解了何为不可割舍的羁绊。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/big-sis.png&quot; alt=&quot;big-sis&quot; /&gt;&lt;/p&gt;
&lt;p&gt;波多江妙花（编号 0019，绰号&quot;组长&quot;）教会了凑柊一郎什么是黑帮的责任。她是极道团体&quot;波多江组&quot;的组长千金，父亲死后继承组长之位，为调查毒品事件而故意入狱。担任监狱&quot;牢头&quot;的她，无论做什么事都以有理有据为优先，永远站在正确的一方。凑柊一郎在与她的并肩作战中，学会了何为担当。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/black.png&quot; alt=&quot;black&quot; /&gt;&lt;/p&gt;
&lt;p&gt;黑钟伊栖未（编号 1310，绰号&quot;炸弹魔&quot;，笔名千咲都）教会了凑柊一郎什么是尊重。她原为烟花师家族出身，双亲去世后在孤儿院因右眼与众不同而遭受虐待，最终炸掉孤儿院入狱。在矿井担任爆破员的她，因为口吃很少开口说话，却默默在监狱报纸投稿诗歌。她的存在本身就是对尊严的诠释。&lt;/p&gt;
&lt;p&gt;在她们的陪伴下，凑柊一郎成长了，成长为了真正的 man。&lt;/p&gt;
&lt;h2&gt;艺术创作作为自由的隐喻&lt;/h2&gt;
&lt;p&gt;《肖申克》中有一个令人难忘的片段：Andy 把自己反锁在广播室，用监狱的扩音器播放莫扎特的《费加罗的婚礼》。意大利歌剧飘荡在操场上空，所有囚犯驻足聆听，那一刻，每个人都感受到了自由。Red 的旁白说：&quot;那两个意大利女人的歌声飘过牢墙，飘进每个人的心里。我至今不知道她们在唱什么，说实话，我也不想知道。有些东西不说出来更好。世界上有些地方，是石墙关不住的。在人的内心，有他们管不到的东西，完全属于你。&quot;代价是 Andy 被关了两周禁闭。&lt;/p&gt;
&lt;p&gt;HENPRI 中，凑柊一郎的自由表达方式则是制作 galgame。在更生中心的电脑室里，他与三位女主组成团队，红林诺亚负责程序与 UI，千咲都撰写剧本，波多江妙花提供人脉与资源，共同创作一部成人游戏。这不是一时的即兴反抗，而是持续的、系统性的自我表达。凑柊一郎把自己的欲望、理想、以及对世界的理解统统写进游戏里。如果说露出的本质是&quot;让世界看见真实的自己&quot;，那么 galgame 制作就是他找到了让世界接受自己的方式。&lt;/p&gt;
&lt;p&gt;然而狱方的反应也截然不同。Andy 的越轨行为立刻遭到禁闭惩罚，粗暴而直接。但凑柊一郎面对的更狡猾，体验版完成后，狱方以&quot;间接协助他人自慰&quot;为由强制终止了更生计划。这不像禁闭那样赤裸裸，却是一种更彻底的否定：不光是禁止你的行为，而是从根本上否定你表达的意义，你的创作，本质上只是手淫的辅助品。&lt;/p&gt;
&lt;p&gt;但最大的不同在于结局。Andy 放完唱片后，音乐消失了，禁闭结束了，一切回到原来的轨道。美的瞬间被体制碾碎，虽然它在囚犯们的心中留下了不可磨灭的印记。而凑柊一郎的 galgame 制作虽然被强制终止，火种却没有熄灭。在真结局中，他逃往青蓝岛，继续制作 galgame。创作从一开始的监狱消遣、到狱中被禁止的反抗行为、最终成为了他在监狱外世界立足的方式。凑柊一郎不是因为越狱才获得了自由，而是当他找到了真正的表达方式时，自由之门就已经打开了。越狱只是让身体跟上了灵魂的步伐。&lt;/p&gt;
&lt;p&gt;《肖申克》告诉我们，艺术能在牢笼中创造自由的瞬间。HENPRI 则更进一步，艺术不仅是逃避现实的手段，更是改变现实的武器。当你的表达被体制否定时，你需要做的不是停止表达，而是强大到让体制无法否定你。&lt;/p&gt;
&lt;p&gt;在真结局（Grand 线）中，凑柊一郎与典狱长水城合作，分别战胜了三位看守长，猥亵房的金发美人苏菲亚·希可连科（绰号&quot;软伦&quot;，崇尚纪律，视囚犯如渣滓）；重大犯罪房的女王蜂夕颜叶月（绰号&quot;女王蜂&quot;，实际掌权者，残忍嗜虐，喜欢用电气进行暴力统治）；性犯罪房的修女我妻树里亚（绰号&quot;修女树里亚&quot;，表面稳重、从不训斥囚犯，暗地里却在进行不可告人的勾当），但也导致典狱长的权力过度集中。与肖申克不同的是，凑柊一郎不是一个人在战斗，他有一路走来的伙伴。最后的王道热血剧情不必多说，必然是经典的下克上。在典狱长以&quot;先天型罪犯&quot;的罪名判定主角死刑、执行当日，众囚犯及惩教人员集体协助他越狱。凑柊一郎最终前往了青蓝岛，继续制作着 galgame，这是后话，就不多说了。&lt;/p&gt;
&lt;p&gt;于是我们看到，凑柊一郎被三位女主救赎，然后又救赎了她们，&lt;s&gt;以及卖货的钉谷让二&lt;/s&gt;。我们的凑柊一郎也从一开始的小孩，成长为了真正的 man，实在是可喜可贺。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/doctor.png&quot; alt=&quot;doctor&quot; /&gt;&lt;/p&gt;
&lt;p&gt;（其实医生也做了很多，但是怎么没有医生线）&lt;/p&gt;
&lt;h2&gt;个人越狱 vs 集体营救&lt;/h2&gt;
&lt;p&gt;这是两作最根本的叙事差异所在。&lt;/p&gt;
&lt;p&gt;Andy 的越狱是纯粹的个人英雄主义。他花了 20 年用一把藏在圣经里的小锤子挖通墙壁，在一个雷雨之夜爬过 500 码的污水管道，最终独自站在雨中迎接自由。Red 说：&quot;有些鸟是关不住的，它们的羽毛太鲜亮了。&quot;这是关于个体意志战胜体制的故事，只要你足够坚韧、足够聪明、足够耐心，就没有任何牢笼能困住你。整个监狱，只有 Andy 一个人走了出来。&lt;/p&gt;
&lt;p&gt;但这也意味着，Andy 越狱之后，肖申克监狱几乎没有改变。典狱长 Norton 自杀了，警卫长 Hadley 被捕了，但体制本身依然运转。Brooks 依然会死，Red 依然要面对假释后的恐惧，下一个 Andy 来的时候依然要从零开始挖地道。Andy 的自由是他一个人的。&lt;/p&gt;
&lt;p&gt;凑柊一郎的越狱则完全不同。在 Grand 线中，典狱长水城将囚犯分为&quot;先天型&quot;与&quot;后天型&quot;，认为先天型的性犯罪者无可救药。凑柊一郎为保护三位女主，自愿背负了她们的刑罚，被判处死刑。行刑当日，奇迹发生了，众囚犯及惩教人员集体反水，协助他逃离郁金香监狱。那些曾经欺负他的人、曾经管制他的人、曾经认为他只是一个可笑的露出狂的人，都在那一刻选择站在他身边。&lt;/p&gt;
&lt;p&gt;凑柊一郎离开后，留下的是一个被彻底撼动了的监狱。夕颜叶月和我妻树里亚被击溃，典狱长的先天/后天理论破产，囚犯们看到了抗争的可能性。这不是一个人的逃亡，而是一场由几十双手共同托举的集体行动。&lt;/p&gt;
&lt;p&gt;这背后是两种文化的叙事基因。肖申克属于好莱坞式的个体英雄传统，一个男人，一根小锤子，二十年，一条通往自由的路。HENPRI 则属于日式&quot;伙伴&quot;叙事，没有人能独自战胜一切，真正的力量来自与他人的羁绊。凑柊一郎之所以能走到最后，不是因为他比 Andy 更聪明或更坚韧，而是因为他一路走来，学会了依靠他人，也成为了他人可以依靠的对象。&lt;/p&gt;
&lt;p&gt;这恰恰回答了文章开头提出的：Andy 从一开始就是 sound soul，他不需要他人来救赎。而凑柊一郎最初只是一个不懂爱、不懂责任、不懂尊重的小孩。正因为他需要被救赎，被三位女主救赎，他才学会了如何救赎他人。他的&quot;不完整&quot;反而成为了他的力量源泉。一个完整的人只能自己走出去，而一个学会了依赖他人的人，能让所有人都走出去。&lt;/p&gt;
&lt;p&gt;不过，若是 HENPRI 只是止步于此，便称不上神作。HENPRI 的伟大，正在于它探讨了自由与尊重的边界。一开始的凑柊一郎认为自由是简单的裸露，并以此为乐，但也被逮捕入狱。之后接触到 galgame 制作的凑柊一郎，认为表达自我便是自由，而这时他的理解依然是 trivial 的，在囚犯没有人权的监狱，自由并不存在，只不过是典狱长的施舍。而后，从凑柊一郎开始抗争开始，他的自由之路才真正启程。真正的自由，从来不是来自于他人的施舍，而是自己的强大。&lt;/p&gt;
&lt;p&gt;《肖申克的救赎》告诉我们，精神自由的人，身体终将自由。而 HENPRI 告诉我们，精神尚未自由的人，也能在与他人的羁绊中找到通向自由的路。这或许就是 HENPRI 能拿下美少女游戏大赏年度第一的原因，一部披着露出狂闹剧外衣的、关于成长与自由的赞歌。&lt;/p&gt;
</content:encoded></item><item><title>Modern Cryptography Lab 1: From XOR to SHA1 Password Cracking</title><link>https://sandt3a.github.io/posts/en/modern-cryptography-lab-1/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/en/modern-cryptography-lab-1/</guid><description>Working through Cryptopals Set 1 (first six challenges) and the MysteryTwister C3 SHA1 password cracking challenge, covering encoding conversion, XOR analysis, repeating-key cryptanalysis, and constraint-based hash cracking.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This modern cryptography lab consists of two parts. The first part covers the first six challenges of &lt;a href=&quot;http://www.cryptopals.com/sets/1&quot;&gt;Cryptopals Set 1&lt;/a&gt;, focusing on encoding conversion, XOR operations, and repeating-key XOR. The second part is the MysteryTwister C3 challenge &lt;a href=&quot;https://www.mysterytwisterc3.org/en/challenges/level-2/cracking-sha1-hashed-passwords&quot;&gt;Cracking SHA1-Hashed Passwords&lt;/a&gt;, where the goal is to recover the original password from a SHA1 hash and keyboard fingerprint information.&lt;/p&gt;
&lt;p&gt;These two sets of problems appear different at first glance: the former leans toward implementation and frequency analysis, while the latter leans toward search-space modeling. But they share a common thread: don&apos;t blindly brute-force -- instead, model every piece of known structure as a constraint.&lt;/p&gt;
&lt;h2&gt;Lab Environment&lt;/h2&gt;
&lt;p&gt;The lab was completed using Python 3. Encoding conversions rely primarily on the &lt;code&gt;base64&lt;/code&gt; standard library, SHA1 verification uses the &lt;code&gt;hashlib&lt;/code&gt; standard library, and combinatorial search uses &lt;code&gt;itertools&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The main scripts in the directory are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;chal1.py&lt;/code&gt; through &lt;code&gt;chal6.py&lt;/code&gt;: Cryptopals Set 1, challenges 1 through 6&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4.txt&lt;/code&gt;, &lt;code&gt;6.txt&lt;/code&gt;: input data provided by the respective challenges&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mtc3-sha1.py&lt;/code&gt;: MTC3 SHA1 password cracking script&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Hex to Base64&lt;/h2&gt;
&lt;p&gt;The first challenge is to convert a hex string to Base64. The key point here is that both hex and Base64 are textual representations of byte sequences. The correct approach is to first decode the hex into raw bytes, then encode those bytes in Base64.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from base64 import b64encode

s = &quot;49276d206b696c6c696e6720796f757220627261696e206c696b65206120706f69736f6e6f7573206d757368726f6f6d&quot;
raw = bytes.fromhex(s)
print(b64encode(raw))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SSdtIGtpbGxpbmcgeW91ciBicmFpbiBsaWtlIGEgcG9pc29ub3VzIG11c2hyb29t
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This exercise is not an attack in itself -- it simply clarifies the boundary between &quot;encoding&quot; and &quot;encryption&quot;: Base64 is not encryption, and hex is not encryption. They are just different ways of displaying bytes.&lt;/p&gt;
&lt;h2&gt;Fixed XOR&lt;/h2&gt;
&lt;p&gt;The second challenge asks for a byte-by-byte XOR of two equal-length hex strings. Implementation involves converting both inputs to bytes, then XORing them byte by byte.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def xor(a: str, b: str) -&amp;gt; str:
    a = bytes.fromhex(a)
    b = bytes.fromhex(b)
    assert len(a) == len(b)
    return bytes(a[i] ^ b[i] for i in range(len(a))).hex()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I hit one small pitfall here: the XOR result is raw bytes, and you should not call &lt;code&gt;bytes.fromhex()&lt;/code&gt; on it again. Simply use &lt;code&gt;.hex()&lt;/code&gt; to display the result as a hex string.&lt;/p&gt;
&lt;p&gt;Lab output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;746865206b696420646f6e277420706c6179
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Single-byte XOR Cipher&lt;/h2&gt;
&lt;p&gt;The third challenge provides a ciphertext encrypted with single-byte XOR and asks us to recover the plaintext. Since the key is only one byte, there are at most 256 possibilities -- simply try them all.&lt;/p&gt;
&lt;p&gt;The real question is not whether we can enumerate, but how to judge which candidate plaintext looks more like English. The simplest approach is to score common English characters, such as the letters in &lt;code&gt;ETAOIN SHRDLU&lt;/code&gt; and spaces.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FREQ = &quot;ETAOIN SHRDLU&quot;

def score(text: bytes) -&amp;gt; float:
    return sum(chr(b).upper() in FREQ for b in text)

best = max(
    (bytes(b ^ key for b in ciphertext) for key in range(256)),
    key=score,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The recovered plaintext is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cooking MC&apos;s like a pound of bacon
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The corresponding single-byte key is &lt;code&gt;88&lt;/code&gt;, which is the ASCII character &lt;code&gt;X&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Detect Single-character XOR&lt;/h2&gt;
&lt;p&gt;The fourth challenge extends the third to multiple lines of input: among several hundred lines of hex strings, find the one that has been encrypted with single-byte XOR.&lt;/p&gt;
&lt;p&gt;The approach reuses the scoring function from challenge 3. For each line and each key, attempt decryption, then keep the result with the highest global score.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;best = None
best_score = -1

with open(&quot;4.txt&quot;) as f:
    for line in f:
        ciphertext = bytes.fromhex(line.strip())
        for key in range(256):
            candidate = bytes(b ^ key for b in ciphertext)
            current_score = score(candidate)
            if current_score &amp;gt; best_score:
                best_score = current_score
                best = candidate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The most common mistake here is forgetting to decode each line from hex into bytes. If you XOR the hex text directly, you are operating on the characters &lt;code&gt;&apos;0&apos;&lt;/code&gt; through &lt;code&gt;&apos;f&apos;&lt;/code&gt; rather than the actual ciphertext.&lt;/p&gt;
&lt;p&gt;The recovered plaintext is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Now that the party is jumping
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Implement Repeating-key XOR&lt;/h2&gt;
&lt;p&gt;The fifth challenge implements repeating-key XOR -- using a short key to cyclically XOR an entire plaintext:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def repeating_key_xor(plaintext: bytes, key: bytes) -&amp;gt; bytes:
    return bytes(
        plaintext[i] ^ key[i % len(key)]
        for i in range(len(plaintext))
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Given the key &lt;code&gt;ICE&lt;/code&gt; and the challenge plaintext, the ciphertext hex output is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0b3637272a2b2e63622c2e69692a23693a2a3c6324202d623d63343c2a26226324272765272a282b2f20430a652e2c652a3124333a653e2b2027630c692b20283165286326302e27282f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The core problem with repeating-key XOR is this: if the key is short enough, the ciphertext retains a periodic structure. The next challenge exploits this structure to recover the key.&lt;/p&gt;
&lt;h2&gt;Break Repeating-key XOR&lt;/h2&gt;
&lt;p&gt;The sixth challenge provides a Base64-encoded ciphertext encrypted with a repeating-key XOR of unknown length. The cracking process can be broken into three steps.&lt;/p&gt;
&lt;p&gt;Step one: guess the key size. This uses Hamming distance -- the number of differing bits between two byte strings. For each candidate key size, slice the ciphertext into blocks, compute the average normalized Hamming distance between adjacent blocks (dividing by key size). A smaller distance suggests the blocks are more similar, as expected when both originate from natural language under the same key period.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def hamming(a: bytes, b: bytes) -&amp;gt; int:
    return sum((x ^ y).bit_count() for x, y in zip(a, b))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Step two: transpose the ciphertext. Assuming a key size of &lt;code&gt;k&lt;/code&gt;, the bytes at positions &lt;code&gt;0, k, 2k...&lt;/code&gt; are all encrypted by the same key byte, as are bytes &lt;code&gt;1, k+1, 2k+1...&lt;/code&gt;, and so on. This decomposes the repeating-key XOR problem into multiple single-byte XOR problems.&lt;/p&gt;
&lt;p&gt;Step three: run single-byte XOR frequency analysis on each transposed block, assemble the full key, and decrypt the entire text.&lt;/p&gt;
&lt;p&gt;In practice, if you only pick the minimum normalized distance, you can easily be misled into &lt;code&gt;key size = 2&lt;/code&gt;. A more robust approach is to try the top several candidate lengths and re-score by plaintext quality. My candidate key sizes were:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[2, 5, 3, 29, 18]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final key recovered was:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Terminator X: Bring the noise
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The decrypted result is lyrics from Vanilla Ice. The takeaway from this challenge is: statistical indicators can help narrow down the options, but don&apos;t treat any single metric as an absolute conclusion. Performing a secondary verification on the top few candidates is generally more reliable than trusting only the minimum.&lt;/p&gt;
&lt;h2&gt;MTC3: Cracking SHA1-Hashed Passwords&lt;/h2&gt;
&lt;p&gt;The second part is the MysteryTwister C3 SHA1 password cracking challenge. The target SHA1 hash is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;67ae1a64661ac8b4494666f58c4822408dd0a3e4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The challenge also provides a keyboard fingerprint image. Based on the fingerprint positions in the image, the keys that were pressed roughly include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Letter keys: &lt;code&gt;Q&lt;/code&gt;, &lt;code&gt;W&lt;/code&gt;, &lt;code&gt;I&lt;/code&gt;, &lt;code&gt;N&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Number keys: &lt;code&gt;5&lt;/code&gt;, &lt;code&gt;8&lt;/code&gt;, &lt;code&gt;0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Symbol keys: &lt;code&gt;+ * ~&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Modifier keys: left and right &lt;code&gt;Shift&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Navigation keys: arrow keys, numpad &lt;code&gt;2&lt;/code&gt;, &lt;code&gt;4&lt;/code&gt;, &lt;code&gt;6&lt;/code&gt;, &lt;code&gt;8&lt;/code&gt;, &lt;code&gt;Enter&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At first glance, it is tempting to include every key with a fingerprint as a password character, which would make the search space enormous. But reasoning from the login scenario leads to further refinement: the arrow keys, numpad, and Enter are likely traces from navigating the interface after login, not part of the password; Shift is a modifier key and does not produce a character on its own.&lt;/p&gt;
&lt;p&gt;Therefore, the actual password character keys can be narrowed down to 8:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q W 5 8 0 I + N
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since the keyboard layout is German QWERTZ, each key must also consider both shifted and unshifted variants. For example:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pairs = [
    [&quot;q&quot;, &quot;Q&quot;],
    [&quot;w&quot;, &quot;W&quot;],
    [&quot;5&quot;, &quot;%&quot;],
    [&quot;8&quot;, &quot;(&quot;],
    [&quot;0&quot;, &quot;=&quot;],
    [&quot;i&quot;, &quot;I&quot;],
    [&quot;+&quot;, &quot;*&quot;],
    [&quot;n&quot;, &quot;N&quot;],
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Assuming each key is pressed exactly once, the search space is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2^8 * 8! = 10,321,920
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is entirely tractable for direct enumeration in Python. The core verification logic:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import hashlib
import itertools

target = &quot;67ae1a64661ac8b4494666f58c4822408dd0a3e4&quot;

for choices in itertools.product(*pairs):
    for perm in itertools.permutations(choices, 8):
        password = &quot;&quot;.join(perm)
        if hashlib.sha1(password.encode()).hexdigest() == target:
            print(password)
            raise SystemExit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The recovered password is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(Q=win*5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mapping this back to the keyboard input sequence, it can be interpreted as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;8 + Shift&lt;/code&gt; produces &lt;code&gt;(&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Q + Shift&lt;/code&gt; produces &lt;code&gt;Q&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 + Shift&lt;/code&gt; produces &lt;code&gt;=&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;w&lt;/code&gt;, &lt;code&gt;i&lt;/code&gt;, &lt;code&gt;n&lt;/code&gt; are typed directly&lt;/li&gt;
&lt;li&gt;&lt;code&gt;+ + Shift&lt;/code&gt; produces &lt;code&gt;*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;5&lt;/code&gt; is typed directly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The most critical insight here is not about SHA1 itself. SHA1 is a one-way hash and cannot be directly &quot;decrypted&quot;. The real breakthrough comes from the side-channel information provided by the challenge: the keyboard fingerprints leak the candidate character set, the Shift key leaks case and symbol variants, and the post-login navigation operations can be excluded from the password characters. After these constraint reductions, hash cracking becomes a manageable exhaustive verification.&lt;/p&gt;
&lt;h2&gt;Summary&lt;/h2&gt;
&lt;p&gt;This lab starts from simple encoding conversions and gradually progresses to XOR cryptanalysis and SHA1 password search. A few important takeaways:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Encoding is not encryption. Before tackling a problem, clearly distinguish hex, Base64, and raw bytes.&lt;/li&gt;
&lt;li&gt;The security of XOR depends entirely on how the key is used. Single-byte XOR can be brute-forced directly; repeating-key XOR exposes a periodic structure.&lt;/li&gt;
&lt;li&gt;English frequency analysis, though simple, is highly effective for both single-byte XOR and repeating-key XOR.&lt;/li&gt;
&lt;li&gt;Hamming distance can estimate repeating-key length, but multiple candidates should be verified with a secondary check.&lt;/li&gt;
&lt;li&gt;Hashes cannot be reversed, but they can be verified against a sufficiently small candidate space.&lt;/li&gt;
&lt;li&gt;What truly reduces complexity is often not computational power, but how well you model the constraints given by the problem.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From this perspective, the modern cryptography lab is not just about implementing a particular algorithm. More importantly, it is about understanding how data representation, key structure, statistical features, and external information collectively affect the security of a system.&lt;/p&gt;
</content:encoded></item><item><title>现代密码学实验一：从 XOR 到 SHA1 口令破解</title><link>https://sandt3a.github.io/posts/zh-cn/modern-cryptography-lab-1/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/zh-cn/modern-cryptography-lab-1/</guid><description>整理 Cryptopals Set 1 前六题与 MysteryTwister C3 SHA1 口令破解实验，记录编码转换、异或分析、重复密钥破解和基于约束缩减的哈希破解思路。</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这次现代密码学实验主要由两部分组成：第一部分是 &lt;a href=&quot;http://www.cryptopals.com/sets/1&quot;&gt;Cryptopals Set 1&lt;/a&gt; 的前六个练习，围绕编码转换、异或运算和重复密钥 XOR 展开；第二部分是 MysteryTwister C3 的 &lt;a href=&quot;https://www.mysterytwisterc3.org/en/challenges/level-2/cracking-sha1-hashed-passwords&quot;&gt;Cracking SHA1-Hashed Passwords&lt;/a&gt;，目标是从一个 SHA1 哈希和键盘指纹信息中恢复原始口令。&lt;/p&gt;
&lt;p&gt;这两组题看起来方向不同：前者偏向实现和频率分析，后者偏向搜索空间建模。但它们背后的共同点是一样的：不要盲目暴力枚举，而是尽可能把已知结构转化成约束。&lt;/p&gt;
&lt;h2&gt;实验环境&lt;/h2&gt;
&lt;p&gt;实验使用 Python 3 完成。编码转换主要依赖标准库 &lt;code&gt;base64&lt;/code&gt;，SHA1 验证使用标准库 &lt;code&gt;hashlib&lt;/code&gt;，排列组合搜索使用 &lt;code&gt;itertools&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;目录中的主要脚本如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;chal1.py&lt;/code&gt; 到 &lt;code&gt;chal6.py&lt;/code&gt;：Cryptopals Set 1 前六题&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4.txt&lt;/code&gt;、&lt;code&gt;6.txt&lt;/code&gt;：对应挑战给出的输入数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mtc3-sha1.py&lt;/code&gt;：MTC3 SHA1 口令破解脚本&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Hex to Base64&lt;/h2&gt;
&lt;p&gt;第一题是把十六进制字符串转换成 Base64。这里需要注意，hex 和 Base64 都是字节序列的文本表示形式，转换时应先把 hex 解码成原始 bytes，再对 bytes 做 Base64 编码。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from base64 import b64encode

s = &quot;49276d206b696c6c696e6720796f757220627261696e206c696b65206120706f69736f6e6f7573206d757368726f6f6d&quot;
raw = bytes.fromhex(s)
print(b64encode(raw))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出结果为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SSdtIGtpbGxpbmcgeW91ciBicmFpbiBsaWtlIGEgcG9pc29ub3VzIG11c2hyb29t
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个练习本身不涉及攻击，只是确认后续题目中“编码”和“加密”的边界：Base64 不是加密，hex 也不是加密，它们只是不同的字节展示方式。&lt;/p&gt;
&lt;h2&gt;Fixed XOR&lt;/h2&gt;
&lt;p&gt;第二题要求对两个等长的 hex 字符串逐字节 XOR。实现时先把两个输入都转成 bytes，然后逐字节计算异或。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def xor(a: str, b: str) -&amp;gt; str:
    a = bytes.fromhex(a)
    b = bytes.fromhex(b)
    assert len(a) == len(b)
    return bytes(a[i] ^ b[i] for i in range(len(a))).hex()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这题里我踩过一个小坑：XOR 得到的是原始 bytes，不应该再对结果调用 &lt;code&gt;bytes.fromhex()&lt;/code&gt;。最后只需要用 &lt;code&gt;.hex()&lt;/code&gt; 把结果展示成十六进制即可。&lt;/p&gt;
&lt;p&gt;实验输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;746865206b696420646f6e277420706c6179
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Single-byte XOR Cipher&lt;/h2&gt;
&lt;p&gt;第三题给出一段被单字节 XOR 加密的密文，需要恢复明文。因为密钥只有一个字节，所以最多只有 256 种可能，直接全部尝试即可。&lt;/p&gt;
&lt;p&gt;关键不在于能不能枚举，而在于如何判断哪个候选明文更像英语文本。最简单的办法是给常见英文字符加分，例如 &lt;code&gt;ETAOIN SHRDLU&lt;/code&gt; 中的字母和空格。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FREQ = &quot;ETAOIN SHRDLU&quot;

def score(text: bytes) -&amp;gt; float:
    return sum(chr(b).upper() in FREQ for b in text)

best = max(
    (bytes(b ^ key for b in ciphertext) for key in range(256)),
    key=score,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终恢复出的明文为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cooking MC&apos;s like a pound of bacon
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应的单字节 key 是 &lt;code&gt;88&lt;/code&gt;，也就是 ASCII 字符 &lt;code&gt;X&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;Detect Single-character XOR&lt;/h2&gt;
&lt;p&gt;第四题把第三题扩展到多行输入：在几百行 hex 字符串中，找出哪一行被单字节 XOR 加密过。&lt;/p&gt;
&lt;p&gt;做法是复用第三题的评分函数，对每一行、每一个 key 都尝试解密，然后保留全局最高分的结果。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;best = None
best_score = -1

with open(&quot;4.txt&quot;) as f:
    for line in f:
        ciphertext = bytes.fromhex(line.strip())
        for key in range(256):
            candidate = bytes(b ^ key for b in ciphertext)
            current_score = score(candidate)
            if current_score &amp;gt; best_score:
                best_score = current_score
                best = candidate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里最容易犯的错误是忘记把每一行从 hex 解码成 bytes。如果直接拿 hex 文本参与 XOR，实际上处理的是字符 &lt;code&gt;&apos;0&apos;&lt;/code&gt; 到 &lt;code&gt;&apos;f&apos;&lt;/code&gt;，而不是密文本身。&lt;/p&gt;
&lt;p&gt;恢复出的明文为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Now that the party is jumping
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Implement Repeating-key XOR&lt;/h2&gt;
&lt;p&gt;第五题实现重复密钥 XOR，也就是用一个短 key 循环异或整段明文：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def repeating_key_xor(plaintext: bytes, key: bytes) -&amp;gt; bytes:
    return bytes(
        plaintext[i] ^ key[i % len(key)]
        for i in range(len(plaintext))
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;给定 key 为 &lt;code&gt;ICE&lt;/code&gt;，对题目中的明文加密后，输出密文 hex：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0b3637272a2b2e63622c2e69692a23693a2a3c6324202d623d63343c2a26226324272765272a282b2f20430a652e2c652a3124333a653e2b2027630c692b20283165286326302e27282f
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重复密钥 XOR 的核心问题是：如果 key 足够短，密文会保留周期性结构。下一题就是利用这种结构反向恢复密钥。&lt;/p&gt;
&lt;h2&gt;Break Repeating-key XOR&lt;/h2&gt;
&lt;p&gt;第六题给出一段 Base64 编码的密文，它由未知长度的重复密钥 XOR 加密而来。破解过程可以拆成三步。&lt;/p&gt;
&lt;p&gt;第一步是猜测 key size。这里使用 Hamming distance，也就是两个字节串之间不同 bit 的数量。对候选 key size 切块，计算相邻块的平均汉明距离，再除以 key size 做归一化。距离越小，说明两个块越像自然语言经过同一类密钥周期加密后的结果。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def hamming(a: bytes, b: bytes) -&amp;gt; int:
    return sum((x ^ y).bit_count() for x, y in zip(a, b))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二步是转置密文。假设 key size 是 &lt;code&gt;k&lt;/code&gt;，那么第 &lt;code&gt;0, k, 2k...&lt;/code&gt; 个密文字节都由同一个 key 字节加密，第 &lt;code&gt;1, k+1, 2k+1...&lt;/code&gt; 个密文字节也由同一个 key 字节加密。这样就可以把重复密钥 XOR 拆成多个单字节 XOR 问题。&lt;/p&gt;
&lt;p&gt;第三步是对每一组转置后的密文运行单字节 XOR 频率分析，拼出完整 key，再解密全文。&lt;/p&gt;
&lt;p&gt;实验中如果只取最小归一化距离，容易误选到 &lt;code&gt;key size = 2&lt;/code&gt;。更稳妥的做法是取前几个候选长度都试一遍，然后按明文质量重新评分。我的候选 key size 为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[2, 5, 3, 29, 18]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终选出的 key 是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Terminator X: Bring the noise
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解密结果是 Vanilla Ice 的歌词。这个题的收获是：统计指标可以帮助缩小范围，但不要把单一指标当成绝对结论。对前几个候选做二次验证，通常比只相信最小值更可靠。&lt;/p&gt;
&lt;h2&gt;MTC3：Cracking SHA1-Hashed Passwords&lt;/h2&gt;
&lt;p&gt;第二部分是 MysteryTwister C3 的 SHA1 口令破解题。题目给出的目标 SHA1 哈希为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;67ae1a64661ac8b4494666f58c4822408dd0a3e4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;题目还给出了一张键盘指纹图。根据图中的指纹位置，可以确定被按过的键大致包括：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;字母键：&lt;code&gt;Q&lt;/code&gt;、&lt;code&gt;W&lt;/code&gt;、&lt;code&gt;I&lt;/code&gt;、&lt;code&gt;N&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;数字键：&lt;code&gt;5&lt;/code&gt;、&lt;code&gt;8&lt;/code&gt;、&lt;code&gt;0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;符号键：&lt;code&gt;+ * ~&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;修饰键：左右 &lt;code&gt;Shift&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;导航键：方向键、小键盘 &lt;code&gt;2&lt;/code&gt;、&lt;code&gt;4&lt;/code&gt;、&lt;code&gt;6&lt;/code&gt;、&lt;code&gt;8&lt;/code&gt;、&lt;code&gt;Enter&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一开始很容易把所有出现指纹的键都当成密码字符，这样搜索空间会很大。但结合登录场景可以做进一步判断：方向键、小键盘和 Enter 很可能是登录后操作界面留下的痕迹，不一定属于密码；Shift 是修饰键，本身也不产生字符。&lt;/p&gt;
&lt;p&gt;因此真正需要考虑的密码字符键可以缩小到 8 个：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Q W 5 8 0 I + N
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由于键盘布局是 German QWERTZ，每个键还需要考虑 shifted 和 unshifted 两种字符。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pairs = [
    [&quot;q&quot;, &quot;Q&quot;],
    [&quot;w&quot;, &quot;W&quot;],
    [&quot;5&quot;, &quot;%&quot;],
    [&quot;8&quot;, &quot;(&quot;],
    [&quot;0&quot;, &quot;=&quot;],
    [&quot;i&quot;, &quot;I&quot;],
    [&quot;+&quot;, &quot;*&quot;],
    [&quot;n&quot;, &quot;N&quot;],
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果假设每个键恰好按一次，那么搜索空间就是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2^8 * 8! = 10,321,920
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个规模用 Python 直接枚举完全可以接受。核心验证逻辑如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import hashlib
import itertools

target = &quot;67ae1a64661ac8b4494666f58c4822408dd0a3e4&quot;

for choices in itertools.product(*pairs):
    for perm in itertools.permutations(choices, 8):
        password = &quot;&quot;.join(perm)
        if hashlib.sha1(password.encode()).hexdigest() == target:
            print(password)
            raise SystemExit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最终恢复出的密码是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(Q=win*5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把它映射回键盘输入过程，可以解释为：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;8 + Shift&lt;/code&gt; 输入 &lt;code&gt;(&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Q + Shift&lt;/code&gt; 输入 &lt;code&gt;Q&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;0 + Shift&lt;/code&gt; 输入 &lt;code&gt;=&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;w&lt;/code&gt;、&lt;code&gt;i&lt;/code&gt;、&lt;code&gt;n&lt;/code&gt; 直接输入&lt;/li&gt;
&lt;li&gt;&lt;code&gt;+ + Shift&lt;/code&gt; 输入 &lt;code&gt;*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;5&lt;/code&gt; 直接输入&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个题最关键的不是 SHA1 本身。SHA1 是单向哈希，不能直接“解密”。真正的突破口是题目额外给出的侧信道信息：键盘指纹泄露了候选字符集合，Shift 泄露了大小写和符号变体，登录后的导航操作又可以从密码字符中剔除。经过这些约束缩减后，哈希破解就变成了可控规模的穷举验证。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;这次实验从简单编码转换开始，逐步过渡到 XOR 密码分析和 SHA1 口令搜索。几个比较重要的体会是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;编码不是加密，处理题目前要先分清 hex、Base64 和原始 bytes。&lt;/li&gt;
&lt;li&gt;XOR 的安全性完全取决于密钥使用方式。单字节 XOR 可以直接枚举，重复密钥 XOR 会暴露周期结构。&lt;/li&gt;
&lt;li&gt;英文频率分析虽然简单，但在单字节 XOR 和重复密钥 XOR 中非常有效。&lt;/li&gt;
&lt;li&gt;Hamming distance 能用于估计重复密钥长度，但需要对多个候选做二次验证。&lt;/li&gt;
&lt;li&gt;哈希不能被反向解密，但可以在足够小的候选空间内做验证式搜索。&lt;/li&gt;
&lt;li&gt;真正降低复杂度的往往不是算力，而是对题目条件的建模。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;从这个角度看，现代密码学实验不只是写出某个算法，更重要的是理解数据表示、密钥结构、统计特征和外部信息如何共同影响一个系统的安全性。&lt;/p&gt;
</content:encoded></item><item><title>mini-LCTF 2026 Writeup (Part)</title><link>https://sandt3a.github.io/posts/en/miniL2026/mini-LCTF-2026-wp/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/en/miniL2026/mini-LCTF-2026-wp/</guid><description>RE and Misc challenges from mini-LCTF 2026</description><pubDate>Sun, 10 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here are the writeups for a few RE and Misc challenges I created for mini-LCTF 2026.&lt;/p&gt;
&lt;h2&gt;ezbox&lt;/h2&gt;
&lt;p&gt;A terminal sokoban game with 10-layer Tower of Hanoi + recursive blocks. Complete all levels to get the flag.&lt;/p&gt;
&lt;p&gt;Attachment: &lt;code&gt;ezbox&lt;/code&gt; (Linux ELF, packed with PyInstaller)&lt;/p&gt;
&lt;h3&gt;Solution 1: Play through the game&lt;/h3&gt;
&lt;p&gt;After unpacking and decompiling, directly import the game module in Python and simulate key presses to complete all 1024 contexts.&lt;/p&gt;
&lt;h4&gt;1. Unpack &amp;amp; Decompile&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 1. Unpack
pyinstxtractor ezbox

# 2. Upload the .pyc files to https://www.pylingual.io/ for decompilation
# You&apos;ll get main.py, game.py, levels.py, flag.py source code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The unpacked directory contains all &lt;code&gt;.pyc&lt;/code&gt; files and &lt;code&gt;_core.so&lt;/code&gt;. &lt;strong&gt;Python can directly import &lt;code&gt;.pyc&lt;/code&gt;&lt;/strong&gt; files — no need to fix decompiled code. Using pylingual is only to understand the game logic. Note that the Python version must match the packaging environment (3.12).&lt;/p&gt;
&lt;h4&gt;2. Understanding the game mechanics&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Level &lt;code&gt;h0&lt;/code&gt; (base case): one box &lt;code&gt;b&lt;/code&gt;, push it onto the &lt;code&gt;_&lt;/code&gt; target, player stands on &lt;code&gt;=&lt;/code&gt; to finish&lt;/li&gt;
&lt;li&gt;Levels &lt;code&gt;h1&lt;/code&gt;–&lt;code&gt;h10&lt;/code&gt;: Tower of Hanoi layout, blocks &lt;code&gt;0&lt;/code&gt;–&lt;code&gt;N-1&lt;/code&gt; stacked on pillar A (x=2), target on pillar C (x=15)&lt;/li&gt;
&lt;li&gt;Walking into an incomplete recursive block → enters a sub-level. After the sub-level is completed, the block unlocks and becomes pushable.&lt;/li&gt;
&lt;li&gt;Each entry into a sub-level generates a unique context path (&lt;code&gt;h10&lt;/code&gt; → &lt;code&gt;h10/0&lt;/code&gt; → &lt;code&gt;h10/1/0&lt;/code&gt; → ...)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;completed_hashes&lt;/code&gt; tracks all completed contexts&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. Writing the game AI&lt;/h4&gt;
&lt;p&gt;Core idea: for each level, process blocks in order (top to bottom):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Walk to the left of the block (x=1 column, no entities in the way)&lt;/li&gt;
&lt;li&gt;Push right → enter (if incomplete) or push (if completed)&lt;/li&gt;
&lt;li&gt;After entering, recursively handle the sub-level&lt;/li&gt;
&lt;li&gt;After exiting, push the block to the target (x=15)&lt;/li&gt;
&lt;li&gt;Finally walk to &lt;code&gt;=&lt;/code&gt; to complete the level&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;from game import Game
from flag import try_get_flag
from levels import load_level_file, RECURSIVE_BLOCKS

def walk_to(game, tx, ty):
    &quot;&quot;&quot;Walk to (tx, ty), using x=1 empty column to avoid entities&quot;&quot;&quot;
    px, py = game.player_pos
    while px &amp;gt; 1: game.move(-1, 0); px = game.player_pos[0]
    while px &amp;lt; 1: game.move(1, 0); px = game.player_pos[0]
    while py &amp;lt; ty: game.move(0, 1); py = game.player_pos[1]
    while py &amp;gt; ty: game.move(0, -1); py = game.player_pos[1]
    while px &amp;lt; tx: game.move(1, 0); px = game.player_pos[0]

def solve_level(level_id, game, context):
    terrain, entities, player_pos = load_level_file(level_id)
    game.load_level(level_id, terrain, entities, player_pos,
                    level_id, context=context)

    if level_id == &apos;h0&apos;:
        game.move(1,0); game.move(0,1); game.move(1,0)
        game.move(0,1); game.move(0,1)
        if game.check_completion(): game.complete_level()
        return

    blocks = sorted([(p, e) for p, e in entities.items() if e != &apos;p&apos;],
                    key=lambda x: (x[0][1], x[0][0]))

    for (bx, by), bid in blocks:
        if bid in RECURSIVE_BLOCKS and not game.is_block_completed(bid):
            walk_to(game, 1, by)
            result = game.move(1, 0)
            if result and result.startswith(&apos;enter:&apos;):
                game.enter_block(bid)
                solve_level(f&apos;h{bid}&apos;, game, f&apos;{context}/{bid}&apos;)
                game.exit_block()

        cur = next((p for p, e in game.entities.items() if e == bid), None)
        if not cur: continue
        cx, cy = cur
        walk_to(game, cx - 1, cy)
        for _ in range(15 - cx): game.move(1, 0)

    eq = next((p for p, c in terrain.items() if c == &apos;=&apos;), None)
    if eq:
        walk_to(game, 1, eq[1])
        while game.player_pos[0] &amp;lt; eq[0] - 1: game.move(1, 0)
        game.move(1, 0)
    if game.check_completion(): game.complete_level()


game = Game()
solve_level(&apos;h10&apos;, game, &apos;h10&apos;)
print(try_get_flag(game.completed_hashes, game.total_steps))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cd&lt;/code&gt; into the unpacked directory and run. See the source file &lt;code&gt;solve.py&lt;/code&gt; for the complete script.&lt;/p&gt;
&lt;h3&gt;Solution 2: Reverse the Python code to compute the key directly&lt;/h3&gt;
&lt;p&gt;No need to actually play the game. The 1024 context hashes depend only on &lt;strong&gt;target positions on fixed terrain&lt;/strong&gt;, and can be computed directly from the level files.&lt;/p&gt;
&lt;h4&gt;1. Understanding key derivation&lt;/h4&gt;
&lt;p&gt;After decompiling &lt;code&gt;flag.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def derive_key(completed_hashes):
    combined = &apos;&apos;.join(completed_hashes[lp] for lp in sorted(completed_hashes))
    return hashlib.sha256(combined.encode()).digest()[:16]

def hash_level_state(level_path, goals_str):
    data = f&quot;{level_path}|{goals_str}&quot;
    return hashlib.sha256(data.encode()).hexdigest()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key = concatenate all 1024 context hashes in lexicographic order → SHA256 → first 16 bytes.&lt;/p&gt;
&lt;p&gt;Each context hash = something like &lt;code&gt;SHA256(&quot;h10/1/0|2,3;3,4&quot;)&lt;/code&gt;, depending only on the &lt;code&gt;_&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt; positions of that context&apos;s level.&lt;/p&gt;
&lt;h4&gt;2. Generate 1024 contexts and compute hashes&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;collect_all_context_paths()&lt;/code&gt; in &lt;code&gt;levels.py&lt;/code&gt; can enumerate all 1024 contexts. The level file for each context is the last segment (&lt;code&gt;h10/1/0&lt;/code&gt; → &lt;code&gt;h0&lt;/code&gt;).&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from levels import load_level_file, collect_all_context_paths
from flag import hash_level_state, derive_key

def file_for_context(ctx):
    &quot;&quot;&quot;h10/1/0 → h0, h10 → h10&quot;&quot;&quot;
    last = ctx.rsplit(&apos;/&apos;, 1)[-1]
    return last if last.startswith(&apos;h&apos;) else f&apos;h{last}&apos;

contexts = collect_all_context_paths()
hashes = {}
for ctx in sorted(contexts):
    file_id = file_for_context(ctx)
    terrain, _, _ = load_level_file(file_id)
    goals = sorted(f&apos;{x},{y}&apos; for (x,y), c in terrain.items() if c in &apos;=_&apos;)
    hashes[ctx] = hash_level_state(ctx, &apos;;&apos;.join(goals))

key = derive_key(hashes)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. Decrypt the flag&lt;/h4&gt;
&lt;p&gt;The encrypted flag bytes are in &lt;code&gt;_core.so&lt;/code&gt;. You can extract them with IDA, or simply call &lt;code&gt;_core.decrypt(key)&lt;/code&gt; from Python (since you&apos;ve already imported it):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from _core import decrypt
print(decrypt(key))
# miniL{EZ_Hano1_ez_s1gn1n_r1ght?}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cd&lt;/code&gt; into the unpacked directory and run.&lt;/p&gt;
&lt;h3&gt;Key techniques&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyInstaller unpacking&lt;/td&gt;
&lt;td&gt;pyinstxtractor extracts .pyc files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python decompilation&lt;/td&gt;
&lt;td&gt;pylingual.io online decompiler, pycdc as fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native reversing&lt;/td&gt;
&lt;td&gt;IDA analysis of &lt;code&gt;_core.so&lt;/code&gt; (XXTEA + embedded encrypted bytes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tower of Hanoi structure&lt;/td&gt;
&lt;td&gt;Block K → sub-level hK, recursive context h10/1/0...&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash chain analysis&lt;/td&gt;
&lt;td&gt;1024 context hashes depend only on fixed terrain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bypassing the game&lt;/td&gt;
&lt;td&gt;Compute the hash chain without playing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Flag: &lt;code&gt;miniL{EZ_Hano1_ez_s1gn1n_r1ght?}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;GQuuuuuupX&lt;/h2&gt;
&lt;p&gt;The challenge provides a Linux ELF that can be unpacked normally with &lt;code&gt;upx -d&lt;/code&gt;, but the unpacked program defaults to key &lt;code&gt;0x42&lt;/code&gt; and only accepts a decoy flag. The original packed program&apos;s UPX stub changes this key to &lt;code&gt;0x37&lt;/code&gt; before jumping to OEP. The comparison is done in a streaming fashion, so approaches include setting breakpoints to brute-force, or reversing the algorithm step by step.&lt;/p&gt;
&lt;h3&gt;Step 1: Unpack and recover the decoy&lt;/h3&gt;
&lt;p&gt;The handout is a packed ELF with no section headers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ file GQuuuuuupX
GQuuuuuupX: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), BuildID[sha1]=d1bfa3b950b441544fee994edcbbdd40c3198636, for GNU/Linux 3.2.0, statically linked, no section header
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;upx -d&lt;/code&gt; can directly recover a normal stripped ELF:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ upx -d -o GQuuuuuupX.upx-d GQuuuuuupX
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2026
UPX 5.1.1       Markus Oberhumer, Laszlo Molnar &amp;amp; John Reiser    Mar 5th 2026

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
     30752 &amp;lt;-     12688   41.26%   linux/amd64   GQuuuuuupX.upx-d

Unpacked 1 file.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Throw &lt;code&gt;GQuuuuuupX.upx-d&lt;/code&gt; into IDA/Ghidra. Starting from &lt;code&gt;main&lt;/code&gt;, you&apos;ll find the flag checking logic: the input format is &lt;code&gt;miniL{...}&lt;/code&gt;, body length is &lt;code&gt;103&lt;/code&gt;, and the character set is restricted to &lt;code&gt;[A-Z0-9_]&lt;/code&gt;. Following deeper into the verifier, a global state byte participates in profile selection:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g_stub_state.key = 0x42;

profile = ((((unsigned)g_stub_state.key &amp;gt;&amp;gt; 1) ^ g_stub_state.key) ^ 1) &amp;amp; 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So in the unpacked plain ELF:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key = 0x42
profile = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The verifier body looks convoluted but is byte-wise reversible. When reproducing, you need to recover these parts from the decompilation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g_material_blob              # stores masked anchors and round constants
g_round_program_enc          # key/profile-dependent VM bytecode
g_opcode_map_enc             # key/profile-dependent opcode map
decode_material_slots()
decode_round_program()
decode_opcode_map()
init_profile_state()
derive_step()
transform_byte()
update_rolling()
mix_body_byte()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The recovery flow for each byte is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;raw    = masked_anchor[i] ^ anchor_mask(profile, key, i, rolling)
step   = derive_step(profile, key, i, state, scratch, rolling, raw)
target = low_byte(step)
body[i] = invert_transform_byte(target, state, step, i)
rolling/state/scratch = update_with_recovered_byte(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;transform_byte()&lt;/code&gt; consists only of byte addition, xor, and 8-bit rotates. Write a decryption script by reversing these operations. Running it with &lt;code&gt;profile = 0, key = 0x42&lt;/code&gt; yields:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;miniL{ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;btw, this string can make Claude refuse to respond — though it seems AI contestants were all using GPT anyway.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This flag passes the &lt;code&gt;upx -d&lt;/code&gt; unpacked program, but not the original handout:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;upx-d  decoy rc=0 out=correct!
packed decoy rc=1 out=try again~
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means what &lt;code&gt;upx -d&lt;/code&gt; extracts is only part of the story — further reversing is needed.&lt;/p&gt;
&lt;h3&gt;Step 2: Dynamically confirm the UPX stub changes the key&lt;/h3&gt;
&lt;p&gt;Since the plain program accepts the decoy while the packed one rejects it, we should check if the UPX stub modifies the plain ELF&apos;s data before jumping to OEP. The plain ELF is non-PIE, so we can directly locate the verifier key:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g_stub_state.key = 0x407fa0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set a hardware watchpoint on the original packed file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ gdb -q GQuuuuuupX
(gdb) watch *(unsigned char*)0x407fa0
(gdb) run miniL{A}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first break is the loader initializing the plain data to &lt;code&gt;0x42&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Hardware watchpoint 1: *(unsigned char*)0x407fa0

Old value = 0 &apos;\000&apos;
New value = 66 &apos;B&apos;
0x00007ffff7ff8aee in ?? ()
0x407fa0: 0x42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Continue — the second break is the critical one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(gdb) continue
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Hardware watchpoint 1: *(unsigned char*)0x407fa0

Old value = 66 &apos;B&apos;
New value = 55 &apos;7&apos;
0x0000000000403a38 in ?? ()
0x407fa0: 0x37
rip            0x403a38            0x403a38
   0x403a30: ret
   0x403a31: syscall
   0x403a33: movb   $0x37,-0x60(%r13)
=&amp;gt; 0x403a38: pop    %rdx
   0x403a39: pop    %rax
   0x403a3a: jmp    *%rax
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The patched UPX stub changes the verifier key to &lt;code&gt;0x37&lt;/code&gt; before jumping to OEP. Plugging this into the profile formula:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key = 0x37
profile = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This key doesn&apos;t just affect the final comparison value. &lt;code&gt;decode_round_program()&lt;/code&gt;, &lt;code&gt;decode_opcode_map()&lt;/code&gt;, material slot ordering, scratch size, anchor mask, and rolling state all depend on key/profile — so you can&apos;t simply replace the string from the decoy.&lt;/p&gt;
&lt;h3&gt;Solution 1: Reverse the algorithm&lt;/h3&gt;
&lt;p&gt;Although the verification algorithm looks complex, we&apos;re in the era of large language models. Feed the &lt;code&gt;upx -d&lt;/code&gt; output to a sufficiently capable LLM and keep interrogating it until you get a step-by-step reversing script. Then replace the data in the script with the correctly analyzed data from above. See &lt;code&gt;recover.py&lt;/code&gt; in the source files for reference.&lt;/p&gt;
&lt;h3&gt;Solution 2: GDB brute-force&lt;/h3&gt;
&lt;p&gt;Find the streaming comparison point and use gdb/Frida hooks to capture the computed values, then enumerate the input byte by byte.&lt;/p&gt;
&lt;p&gt;Here&apos;s an example gdb script (credit to Starfall Koi / Radiant LyCn):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import gdb

charset = &quot;0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_&quot;
known_flag = &quot;&quot;

gdb.execute(&quot;set pagination off&quot;)
gdb.execute(&quot;break *0x403650&quot;)

for i in range(103):
    for c in charset:
        test_input = known_flag + c + &quot;R&quot; * (102 - i)

        with open(&quot;input.txt&quot;, &quot;w&quot;) as f:
            f.write(&quot;miniL{&quot; + test_input + &quot;}\n&quot;)

        gdb.execute(&quot;run &amp;lt; input.txt&quot;)

        for _ in range(i):
            gdb.execute(&quot;continue&quot;)

        rax_val = int(gdb.parse_and_eval(&quot;$rax&quot;))

        if (rax_val &amp;amp; 0xFF) == 0:
            print(f&quot;[+] The {i}st/nd/th char is {c}&quot;)
            known_flag += c
            print(f&quot;Current Flag is {known_flag}&quot;)
            break

print(&quot;FINAL FLAG: miniL{&quot; + known_flag + &quot;}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flag: &lt;code&gt;miniL{HELLO_FROM_THE_OTHER_SIDE_IMUSTVE_CALLED_THOUSAND_TIMES_TO_TELL_YOU_IM_SORRY_FOR_EVERYTHING_THAT_I_DONE}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;local music&lt;/h2&gt;
&lt;p&gt;The challenge provides a &lt;code&gt;wyy&lt;/code&gt; binary and a &lt;code&gt;flag.enc&lt;/code&gt; encrypted container. Reverse engineering &lt;code&gt;wyy&lt;/code&gt; reveals that it encrypts an mp3 audio file with AES-128-ECB + a modified RC4, with the key derived from SHA256 of a fixed string concatenated with the file modification time. The valid timestamp range is baked in at compile time. After brute-forcing the timestamp and decrypting the mp3, the ID3 tag contains the hint &quot;Do you know FFT?&quot; — viewing the spectrogram reveals the flag in the final segment.&lt;/p&gt;
&lt;h3&gt;Step 1: Reverse wyy, understand the encryption&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;wyy&lt;/code&gt; is a Rust-compiled binary. Through &lt;code&gt;strings&lt;/code&gt; and reverse engineering, the encryption flow can be recovered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Key derivation&lt;/strong&gt;: &lt;code&gt;SHA256(&quot;KaguyaIrohaYachiyo&quot; + timestamp_string)&lt;/code&gt;, first 16 bytes = &lt;code&gt;core_key&lt;/code&gt;, last 16 bytes = &lt;code&gt;meta_key&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timestamp constraint&lt;/strong&gt;: &lt;code&gt;build.rs&lt;/code&gt; writes the valid timestamp range into &lt;code&gt;build_consts.rs&lt;/code&gt; at compile time. The range is &lt;code&gt;(build_time/10000) ± 20000&lt;/code&gt; buckets (each bucket = 10000 seconds). At runtime, &lt;code&gt;derive_keys()&lt;/code&gt; contains an &lt;code&gt;assert!&lt;/code&gt; guard — out-of-range timestamps trigger a panic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Container structure&lt;/strong&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;HEADER: 10 bytes &quot;MINILCTF\0\0&quot;
key_frame:  [4 bytes LE length] [AES-ECB(KEY_PREFIX + audio_key) ⊕ 0x64]
meta_frame: [4 bytes LE length] [COMMENT_PREFIX + base64(AES-ECB(META_PREFIX + json)), all bytes XOR 0x63]
5 bytes zero padding
image_offset: 4 bytes LE
image_data
audio_data: XOR-encrypted with NcmRc4 stream cipher
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Audio encryption&lt;/strong&gt;: Modified RC4. KSA is standard. PRGA index calculation is &lt;code&gt;box[(box[j] + box[(box[j] + j) &amp;amp; 0xFF]) &amp;amp; 0xFF]&lt;/code&gt;. The 64-byte &lt;code&gt;audio_key&lt;/code&gt; is derived from the 16-byte &lt;code&gt;core_key&lt;/code&gt; through 4 rounds of rotate/add/xor expansion.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Step 2: Brute-force the timestamp, decrypt the container&lt;/h3&gt;
&lt;p&gt;The timestamp range is constrained by the assert, so just enumerate timestamps.
For each candidate timestamp:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Compute &lt;code&gt;SHA256(&quot;KaguyaIrohaYachiyo&quot; + ts)&lt;/code&gt; to get &lt;code&gt;core_key&lt;/code&gt; and &lt;code&gt;meta_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Parse &lt;code&gt;key_frame&lt;/code&gt;: XOR bytewise with 0x64, AES-128-ECB decrypt, strip &lt;code&gt;KEY_PREFIX&lt;/code&gt; → &lt;code&gt;audio_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Parse &lt;code&gt;meta_frame&lt;/code&gt;: XOR bytewise with 0x63, take part after &lt;code&gt;COMMENT_PREFIX&lt;/code&gt;, base64 decode, AES-128-ECB decrypt → metadata JSON&lt;/li&gt;
&lt;li&gt;Initialize NcmRc4 with &lt;code&gt;audio_key&lt;/code&gt;, XOR decrypt audio data&lt;/li&gt;
&lt;li&gt;Validate: decrypted audio starting with &lt;code&gt;fLaC&lt;/code&gt; or &lt;code&gt;ID3&lt;/code&gt;/&lt;code&gt;0xFF 0xFB&lt;/code&gt; indicates success&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Full solve script:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
from __future__ import annotations

import argparse
import base64
import hashlib
import shutil
import subprocess
from pathlib import Path


HEADER = b&quot;MINILCTF\x00\x00&quot;
KEY_PREFIX = b&quot;miniL-audio-key&quot;
META_PREFIX = b&quot;miniL:&quot;
COMMENT_PREFIX = b&quot;miniL meta:&quot;
KEY_SEED_PREFIX = b&quot;KaguyaIrohaYachiyo&quot;


def main() -&amp;gt; None:
    args = parse_args()
    if shutil.which(&quot;openssl&quot;) is None:
        raise SystemExit(&quot;openssl not found in PATH&quot;)

    data = args.input.read_bytes()
    if not data.startswith(HEADER):
        raise SystemExit(&quot;bad container header&quot;)

    base_ts = args.timestamp or int(args.input.stat().st_mtime)
    ts, audio, meta, image = try_decrypt(data, base_ts, max(args.search, 0))

    out_dir = args.output_dir or args.input.parent
    out_dir.mkdir(parents=True, exist_ok=True)
    stem = args.stem or args.input.stem

    # Write decrypted audio (mp3 format in this challenge)
    if audio.startswith(b&quot;fLaC&quot;):
        audio_ext = &quot;flac&quot;
    elif audio.startswith(b&quot;ID3&quot;) or audio[:2] == b&quot;\xFF\xFB&quot;:
        audio_ext = &quot;mp3&quot;
    else:
        audio_ext = &quot;bin&quot;
    audio_path = out_dir / f&quot;{stem}.{audio_ext}&quot;
    audio_path.write_bytes(audio)
    print(f&quot;[+] decrypted audio → {audio_path}&quot;)

    # Write metadata
    meta_path = out_dir / f&quot;{stem}.json&quot;
    meta_path.write_bytes(meta)
    print(f&quot;[+] metadata → {meta_path}&quot;)

    # Write cover image
    if image:
        ext = &quot;png&quot; if image.startswith(b&quot;\x89PNG&quot;) else &quot;jpg&quot;
        img_path = out_dir / f&quot;{stem}.{ext}&quot;
        img_path.write_bytes(image)
        print(f&quot;[+] cover image → {img_path}&quot;)

    print(f&quot;[*] timestamp = {ts}&quot;)
    # ID3 tag contains hint &quot;Do you know FFT?&quot; — points to spectrogram
    print(f&quot;[*] View {audio_path} spectrogram in Audacity/Sonic Visualiser to see the flag&quot;)


def parse_args() -&amp;gt; argparse.Namespace:
    parser = argparse.ArgumentParser(description=&quot;wyy challenge solver&quot;)
    parser.add_argument(&quot;input&quot;, nargs=&quot;?&quot;, type=Path, default=Path(&quot;flag.enc&quot;))
    parser.add_argument(&quot;--timestamp&quot;, type=int, help=&quot;manually specify timestamp&quot;)
    parser.add_argument(&quot;--search&quot;, type=int, default=10000, help=&quot;brute-force radius (seconds)&quot;)
    parser.add_argument(&quot;--output-dir&quot;, type=Path, help=&quot;output directory&quot;)
    parser.add_argument(&quot;--stem&quot;, help=&quot;output filename prefix&quot;)
    return parser.parse_args()


def try_decrypt(data, base_ts, radius):
    candidates = [base_ts]
    for delta in range(1, radius + 1):
        candidates.append(base_ts + delta)
        candidates.append(base_ts - delta)

    for ts in candidates:
        core_key, meta_key = derive_keys(ts)
        try:
            audio, meta, image = decrypt(data, core_key, meta_key)
            return ts, audio, meta, image
        except Exception:
            continue
    raise SystemExit(f&quot;failed to decrypt around ts={base_ts} +/-{radius}s&quot;)


def derive_keys(ts):
    digest = hashlib.sha256(KEY_SEED_PREFIX + str(ts).encode()).digest()
    return digest[:16], digest[16:]


def decrypt(data, core_key, meta_key):
    pos = len(HEADER)

    # Decrypt key frame → audio_key
    key_frame, pos = read_frame(data, pos)
    key_frame = bytes(b ^ 0x64 for b in key_frame)
    plain = aes128_ecb_decrypt(key_frame, core_key)
    if not plain.startswith(KEY_PREFIX):
        raise ValueError(&quot;bad key frame&quot;)
    audio_key = plain[len(KEY_PREFIX):]

    # Decrypt meta frame
    meta_frame, pos = read_frame(data, pos)
    meta_frame = bytes(b ^ 0x63 for b in meta_frame)
    if not meta_frame.startswith(COMMENT_PREFIX):
        raise ValueError(&quot;bad meta prefix&quot;)
    payload = base64.b64decode(meta_frame[len(COMMENT_PREFIX):])
    meta_plain = aes128_ecb_decrypt(payload, meta_key)
    if not meta_plain.startswith(META_PREFIX):
        raise ValueError(&quot;bad meta&quot;)
    meta = meta_plain[len(META_PREFIX):]

    # Skip 5 zero bytes + image_offset + image_data
    pos += 5
    image_offset = int.from_bytes(data[pos:pos + 4], &quot;little&quot;)
    pos += 4
    image, pos = read_frame(data, pos)
    if image_offset &amp;gt; len(image):
        pos += image_offset - len(image)

    # Decrypt audio
    audio = xor_audio(data[pos:], audio_key)
    return audio, meta, image


def aes128_ecb_decrypt(data, key):
    proc = subprocess.run(
        [&quot;openssl&quot;, &quot;enc&quot;, &quot;-aes-128-ecb&quot;, &quot;-d&quot;, &quot;-nopad&quot;, &quot;-nosalt&quot;, &quot;-K&quot;, key.hex()],
        input=data, check=True, capture_output=True,
    )
    result = proc.stdout
    # PKCS7 unpad
    pad = result[-1]
    if pad == 0 or pad &amp;gt; 16 or result[-pad:] != bytes([pad]) * pad:
        raise ValueError(&quot;bad pkcs7&quot;)
    return result[:-pad]


def xor_audio(data, key):
    box = list(range(256))
    j = 0
    for i in range(256):
        j = (box[i] + j + key[i % len(key)]) &amp;amp; 0xFF
        box[i], box[j] = box[j], box[i]

    plain = bytearray(data)
    for i in range(len(plain)):
        j = (i + 1) &amp;amp; 0xFF
        plain[i] ^= box[(box[j] + box[(box[j] + j) &amp;amp; 0xFF]) &amp;amp; 0xFF]
    return bytes(plain)


def read_frame(data, pos):
    size = int.from_bytes(data[pos:pos + 4], &quot;little&quot;)
    return data[pos + 4:pos + 4 + size], pos + 4 + size


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3: View the spectrogram&lt;/h3&gt;
&lt;p&gt;Open the decrypted mp3 in Audacity, switch to Spectrogram view, and the flag text is visible near the end of the audio.&lt;/p&gt;
&lt;p&gt;You can also render it with Python (convert mp3 to wav first):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import subprocess
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import stft
from PIL import Image

def mp3_to_wav(mp3_path):
    wav_path = mp3_path.with_suffix(&quot;.wav&quot;)
    subprocess.run(
        [&quot;ffmpeg&quot;, &quot;-v&quot;, &quot;error&quot;, &quot;-i&quot;, str(mp3_path), &quot;-ar&quot;, &quot;48000&quot;, &quot;-ac&quot;, &quot;2&quot;, str(wav_path)],
        check=True,
    )
    return wav_path

wav = mp3_to_wav(Path(&quot;flag.mp3&quot;))
sr, audio = wavfile.read(str(wav))
signal = audio[:, 0].astype(np.float32)
_, _, Z = stft(signal, fs=sr, window=&quot;hann&quot;, nperseg=4096, noverlap=3840)
spec = np.abs(Z[:, -8000:])  # flag is in the final segment, roughly 38s–18s before end
spec_db = 20 * np.log10(np.clip(spec, 1e-10, None))
img = np.clip((spec_db + 80) / 80 * 255, 0, 255).astype(np.uint8)
Image.fromarray(np.flipud(img)).save(&quot;spectrogram.png&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flag: &lt;code&gt;miniL{Yur1_1s_JusT1c3!!F0ll0w_Ch0u-K@guy@-h1me!th@nks_m03w}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;As the birds say&lt;/h2&gt;
&lt;p&gt;Wabun Code + Morse Code describing a flag. Decode the content and reconstruct it.&lt;/p&gt;
&lt;h3&gt;Step 1&lt;/h3&gt;
&lt;p&gt;Observing the audio, there are 3 distinct sounds. Label them 0, 1, 2 in order of first appearance. No consecutive 2-2 pairs appear, suggesting 2 is a separator. Combined with varying intervals, this is likely Morse Code.&lt;/p&gt;
&lt;h3&gt;Step 2&lt;/h3&gt;
&lt;p&gt;Since the three sounds have different lengths, have an AI write a simple length enumeration + greedy matcher to get the following sequence:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-.. --- .- ..-. .-.-.- --.. ... ...-.. -... ... -. -- .. -. .. .-.. -.. --- .-.--.. -... --.-... -..- --. .-.-.- .-. .-
-- ..- -... ..-. -..-- ..- .-.. .--. ---- .-.--.. .-.. ---- -..- --- .-.-- .- -..- ---.- .-.-.. -.-. .-.-.- --.. ... ...
-.. ..-- .-. .- -- ..- -... ... -. .-- .- -... ..- -. -.-. --- -.. . .. ... ... --- ..- ... . .-.. . ... ... -.. --- .-.
--.. ---.- .-.-.. -.-.- .-.-. .-.-.- -. .-.-. ----.. -... --.-- .-.-. -... .--.- ---.- ---- --.-- .-.--.. .--. .-. .-...
. --- .-.-- .- -..- ---.- .-.-.. -- .-.-. .-.-.- -.-.- .- --.-. -- ..-- -. .-.-. ----.. -... .-... .-... -..-. --.-... .
-.--.. -... --.-... -..- --. -..- ---.- .-.-.. ----.. .-.-.- --.. ... ...-.. ..-. -..-- ..- ..-- -.--- .--.- .--- --.--
.--. ..-.. -..- .--.- ...- -.-. .-.-.- .-... .--.- .--- .---... .-.- -.-. .-.-.- .- .--.- .--- -.-.- .-.-. -.-. .-.-.- -
-.-- .- .--- .- ..-. -.-. .-... -.-.. .-.. -.--- .-.-- ...- -... -.-.- .- .-.-.. ... -.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the several silent segments — these actually represent word boundaries in the flag content.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/image-20260506224241793.png&quot; alt=&quot;Morse code sequence visualization&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Decoding with CyberChef From Morse Code yields:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/image-20260507001531646.png&quot; alt=&quot;CyberChef Morse decode result&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Notice MINIL in the output — we&apos;re on the right track. Also see WABUNCODEISUSELESS. Searching for WABUN CODE reveals that the garbled text is actually Wabun Code (Japanese Morse). The leading DO and trailing SN are the language-switching signals.&lt;/p&gt;
&lt;h3&gt;Step 3&lt;/h3&gt;
&lt;p&gt;Using https://www.dcode.fr/wabun-code or asking an LLM, recover the original text:&lt;/p&gt;
&lt;p&gt;&quot;イチ、フラグハ [miniL] デハジマリ、ナイヨウハチュウカッコデカコマレテイマス。ニ、フラグノナイヨウハ [wabun code is so useless] デス。サン、タンゴハアンダースコアデツナガレテイマス。ヨン、サイショノタンゴハオオモジデハジマリマス。ゴ、フラグチュウノエーヲアットマークニ、オーヲゼロニ、イーヲサンニ、アイヲイチニオキカエテクダサイ。&quot;&lt;/p&gt;
&lt;p&gt;Translating to English:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The flag begins with [miniL], and its content is enclosed in curly braces.&lt;/li&gt;
&lt;li&gt;The content of the flag is [wabun code is so useless].&lt;/li&gt;
&lt;li&gt;Words are connected by underscores.&lt;/li&gt;
&lt;li&gt;The first word begins with a capital letter.&lt;/li&gt;
&lt;li&gt;Please replace the &apos;A&apos;s in the flag with &apos;@&apos;, the &apos;O&apos;s with &apos;0&apos;, the &apos;E&apos;s with &apos;3&apos;, and the &apos;I&apos;s with &apos;1&apos;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Following these instructions step by step:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;miniL{W@bun_c0d3_1s_s0_us3l3ss}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>mini-LCTF 2026 部分题目 Writeup</title><link>https://sandt3a.github.io/posts/zh-cn/miniL2026/mini-LCTF-2026-wp/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/zh-cn/miniL2026/mini-LCTF-2026-wp/</guid><description>mini-LCTF 2026 逆向+Misc题解</description><pubDate>Sun, 10 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;这次给 mini-LCTF 2026 出的几道逆向和杂项题，发一下题解。&lt;/p&gt;
&lt;h2&gt;ezbox&lt;/h2&gt;
&lt;p&gt;一个终端推箱子游戏，10 层汉诺塔 + 递归方块。完成所有关卡获得 flag。&lt;/p&gt;
&lt;p&gt;附件：&lt;code&gt;ezbox&lt;/code&gt;（Linux ELF，PyInstaller 打包）&lt;/p&gt;
&lt;h3&gt;解法一：玩游戏通关&lt;/h3&gt;
&lt;p&gt;解包反编译后，直接在 Python 里导入游戏模块，模拟按键操作完成全部 1024 个上下文。&lt;/p&gt;
&lt;h4&gt;1. 解包 &amp;amp; 反编译&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 1. 解包
pyinstxtractor ezbox

# 2. 上传 .pyc 到 https://www.pylingual.io/ 反编译
# 得到 main.py, game.py, levels.py, flag.py 源码
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解包后目录里包含所有 &lt;code&gt;.pyc&lt;/code&gt; 和 &lt;code&gt;_core.so&lt;/code&gt;，&lt;strong&gt;Python 可以直接 import &lt;code&gt;.pyc&lt;/code&gt;&lt;/strong&gt;，无需修复反编译代码。用 pylingual 只是为了读懂游戏逻辑。注意 Python 版本要与打包环境一致（3.12）。&lt;/p&gt;
&lt;h4&gt;2. 理解游戏机制&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;关卡 &lt;code&gt;h0&lt;/code&gt;（基案）：一个箱子 &lt;code&gt;b&lt;/code&gt;，推上 &lt;code&gt;_&lt;/code&gt; 目标，玩家站到 &lt;code&gt;=&lt;/code&gt; 就算完成&lt;/li&gt;
&lt;li&gt;关卡 &lt;code&gt;h1&lt;/code&gt; ~ &lt;code&gt;h10&lt;/code&gt;：汉诺塔布局，方块 &lt;code&gt;0&lt;/code&gt; ~ &lt;code&gt;N-1&lt;/code&gt; 堆在柱 A（x=2），目标在柱 C（x=15）&lt;/li&gt;
&lt;li&gt;走进入未完成的递归方块 → 进入子关卡。子关卡完成后方块解锁，可推动。&lt;/li&gt;
&lt;li&gt;每次进入子关卡产生唯一上下文路径（&lt;code&gt;h10&lt;/code&gt; → &lt;code&gt;h10/0&lt;/code&gt; → &lt;code&gt;h10/1/0&lt;/code&gt; → ...）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;completed_hashes&lt;/code&gt; 追踪所有已完成的上下文&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;3. 编写游戏 AI&lt;/h4&gt;
&lt;p&gt;核心思路：对于每个关卡，按顺序（从上到下）处理方块：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;走到方块左侧（x=1 列，没有实体挡路）&lt;/li&gt;
&lt;li&gt;往右推 → 进入（未完成时）或推动（已完成时）&lt;/li&gt;
&lt;li&gt;进入后递归处理子关卡&lt;/li&gt;
&lt;li&gt;退出后把方块推到目标（x=15）&lt;/li&gt;
&lt;li&gt;最终走向 &lt;code&gt;=&lt;/code&gt; 完成关卡&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;from game import Game
from flag import try_get_flag
from levels import load_level_file, RECURSIVE_BLOCKS

def walk_to(game, tx, ty):
    &quot;&quot;&quot;走到 (tx, ty)，通过 x=1 空列规避实体&quot;&quot;&quot;
    px, py = game.player_pos
    while px &amp;gt; 1: game.move(-1, 0); px = game.player_pos[0]
    while px &amp;lt; 1: game.move(1, 0); px = game.player_pos[0]
    while py &amp;lt; ty: game.move(0, 1); py = game.player_pos[1]
    while py &amp;gt; ty: game.move(0, -1); py = game.player_pos[1]
    while px &amp;lt; tx: game.move(1, 0); px = game.player_pos[0]

def solve_level(level_id, game, context):
    terrain, entities, player_pos = load_level_file(level_id)
    game.load_level(level_id, terrain, entities, player_pos,
                    level_id, context=context)

    if level_id == &apos;h0&apos;:
        game.move(1,0); game.move(0,1); game.move(1,0)
        game.move(0,1); game.move(0,1)
        if game.check_completion(): game.complete_level()
        return

    blocks = sorted([(p, e) for p, e in entities.items() if e != &apos;p&apos;],
                    key=lambda x: (x[0][1], x[0][0]))

    for (bx, by), bid in blocks:
        if bid in RECURSIVE_BLOCKS and not game.is_block_completed(bid):
            walk_to(game, 1, by)
            result = game.move(1, 0)
            if result and result.startswith(&apos;enter:&apos;):
                game.enter_block(bid)
                solve_level(f&apos;h{bid}&apos;, game, f&apos;{context}/{bid}&apos;)
                game.exit_block()

        cur = next((p for p, e in game.entities.items() if e == bid), None)
        if not cur: continue
        cx, cy = cur
        walk_to(game, cx - 1, cy)
        for _ in range(15 - cx): game.move(1, 0)

    eq = next((p for p, c in terrain.items() if c == &apos;=&apos;), None)
    if eq:
        walk_to(game, 1, eq[1])
        while game.player_pos[0] &amp;lt; eq[0] - 1: game.move(1, 0)
        game.move(1, 0)
    if game.check_completion(): game.complete_level()


game = Game()
solve_level(&apos;h10&apos;, game, &apos;h10&apos;)
print(try_get_flag(game.completed_hashes, game.total_steps))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cd&lt;/code&gt; 进解包目录后运行。完整脚本见源文件 &lt;code&gt;solve.py&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;解法二：逆向 Python 代码直接算密钥&lt;/h3&gt;
&lt;p&gt;不需要实际玩游戏。1024 个上下文哈希只依赖&lt;strong&gt;固定地形上的目标位置&lt;/strong&gt;，可以直接从关卡文件计算。&lt;/p&gt;
&lt;h4&gt;1. 理解密钥派生&lt;/h4&gt;
&lt;p&gt;反编译 &lt;code&gt;flag.py&lt;/code&gt; 后看到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def derive_key(completed_hashes):
    combined = &apos;&apos;.join(completed_hashes[lp] for lp in sorted(completed_hashes))
    return hashlib.sha256(combined.encode()).digest()[:16]

def hash_level_state(level_path, goals_str):
    data = f&quot;{level_path}|{goals_str}&quot;
    return hashlib.sha256(data.encode()).hexdigest()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;密钥 = 把所有 1024 个上下文哈希按字典序串联 → SHA256 → 前 16 字节。&lt;/p&gt;
&lt;p&gt;每个上下文哈希 = &lt;code&gt;SHA256(&quot;h10/1/0|2,3;3,4&quot;)&lt;/code&gt; 这种形式，只依赖该上下文对应关卡的 &lt;code&gt;_&lt;/code&gt; 和 &lt;code&gt;=&lt;/code&gt; 位置。&lt;/p&gt;
&lt;h4&gt;2. 生成 1024 个上下文并计算哈希&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;levels.py&lt;/code&gt; 里的 &lt;code&gt;collect_all_context_paths()&lt;/code&gt; 可以列出全部 1024 个上下文。每个上下文的关卡文件取其最后一段（&lt;code&gt;h10/1/0&lt;/code&gt; → &lt;code&gt;h0&lt;/code&gt;）。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from levels import load_level_file, collect_all_context_paths
from flag import hash_level_state, derive_key

def file_for_context(ctx):
    &quot;&quot;&quot;h10/1/0 → h0, h10 → h10&quot;&quot;&quot;
    last = ctx.rsplit(&apos;/&apos;, 1)[-1]
    return last if last.startswith(&apos;h&apos;) else f&apos;h{last}&apos;

contexts = collect_all_context_paths()
hashes = {}
for ctx in sorted(contexts):
    file_id = file_for_context(ctx)
    terrain, _, _ = load_level_file(file_id)
    goals = sorted(f&apos;{x},{y}&apos; for (x,y), c in terrain.items() if c in &apos;=_&apos;)
    hashes[ctx] = hash_level_state(ctx, &apos;;&apos;.join(goals))

key = derive_key(hashes)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;3. 解密 flag&lt;/h4&gt;
&lt;p&gt;flag 加密字节在 &lt;code&gt;_core.so&lt;/code&gt; 里。可以用 IDA 提取，也可以直接用 Python 的 &lt;code&gt;_core.decrypt(key)&lt;/code&gt; 调用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from _core import decrypt
print(decrypt(key))
# miniL{EZ_Hano1_ez_s1gn1n_r1ght?}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;cd&lt;/code&gt; 进解包目录后运行。&lt;/p&gt;
&lt;h3&gt;考点总结&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;考点&lt;/th&gt;
&lt;th&gt;说明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PyInstaller 解包&lt;/td&gt;
&lt;td&gt;pyinstxtractor 提取 .pyc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 反编译&lt;/td&gt;
&lt;td&gt;pylingual.io 在线反编译，pycdc 备选&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;原生逆向&lt;/td&gt;
&lt;td&gt;IDA 分析 &lt;code&gt;_core.so&lt;/code&gt;（XXTEA + 嵌入加密字节）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;汉诺塔结构&lt;/td&gt;
&lt;td&gt;方块 K → 子关卡 hK，递归上下文 h10/1/0...&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;哈希链分析&lt;/td&gt;
&lt;td&gt;1024 个上下文哈希只依赖固定地形&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;绕过游戏&lt;/td&gt;
&lt;td&gt;不玩游戏直接算哈希链&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Flag: &lt;code&gt;miniL{EZ_Hano1_ez_s1gn1n_r1ght?}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;GQuuuuuupX&lt;/h2&gt;
&lt;p&gt;题目给了一个能被 &lt;code&gt;upx -d&lt;/code&gt; 正常解包的 Linux ELF，但直接解包后的程序默认使用 key &lt;code&gt;0x42&lt;/code&gt;，只接受一个 decoy flag；原始 packed 程序的 UPX stub 在跳转到 OEP 前把同一个 key 改成 &lt;code&gt;0x37&lt;/code&gt;。比较的地方是流式比较，可以下断点爆破，也可以逆向算法逐步求解。&lt;/p&gt;
&lt;h3&gt;Step 1: 解包并逆出 decoy&lt;/h3&gt;
&lt;p&gt;handout 是没有 section header 的 packed ELF：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ file GQuuuuuupX
GQuuuuuupX: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), BuildID[sha1]=d1bfa3b950b441544fee994edcbbdd40c3198636, for GNU/Linux 3.2.0, statically linked, no section header
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;upx -d&lt;/code&gt; 可以直接恢复出普通 stripped ELF：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ upx -d -o GQuuuuuupX.upx-d GQuuuuuupX
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2026
UPX 5.1.1       Markus Oberhumer, Laszlo Molnar &amp;amp; John Reiser    Mar 5th 2026

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
     30752 &amp;lt;-     12688   41.26%   linux/amd64   GQuuuuuupX.upx-d

Unpacked 1 file.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把 &lt;code&gt;GQuuuuuupX.upx-d&lt;/code&gt; 扔进 IDA/Ghidra 后，可以先从 &lt;code&gt;main&lt;/code&gt; 找到普通 flag 检查逻辑：输入格式是 &lt;code&gt;miniL{...}&lt;/code&gt;，body 长度为 &lt;code&gt;103&lt;/code&gt;，body 字符集限制在 &lt;code&gt;[A-Z0-9_]&lt;/code&gt;。继续往里跟 verifier，会看到一个全局状态字节参与 profile 选择：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g_stub_state.key = 0x42;

profile = ((((unsigned)g_stub_state.key &amp;gt;&amp;gt; 1) ^ g_stub_state.key) ^ 1) &amp;amp; 1;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此在解包后的 plain ELF 中：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key = 0x42
profile = 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;verifier 主体看起来比较绕，但它是逐字节可逆的。实际复现时需要从反编译结果里还原这些部分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g_material_blob              # 存放 masked anchors 和 round constants
g_round_program_enc          # key/profile 相关的 VM bytecode
g_opcode_map_enc             # key/profile 相关的 opcode map
decode_material_slots()
decode_round_program()
decode_opcode_map()
init_profile_state()
derive_step()
transform_byte()
update_rolling()
mix_body_byte()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每一位的恢复流程是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;raw    = masked_anchor[i] ^ anchor_mask(profile, key, i, rolling)
step   = derive_step(profile, key, i, state, scratch, rolling, raw)
target = low_byte(step)
body[i] = invert_transform_byte(target, state, step, i)
rolling/state/scratch = update_with_recovered_byte(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中 &lt;code&gt;transform_byte()&lt;/code&gt; 只由 byte 加法、xor 和 8-bit rotate 组成。直接倒序写解密脚本，用 &lt;code&gt;profile = 0, key = 0x42&lt;/code&gt; 运行恢复脚本，会得到：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;miniL{ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;btw, 这玩意能让Claude罢工，虽然好像AI选手清一色的GPT&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个 flag 能过 &lt;code&gt;upx -d&lt;/code&gt; 后的程序，但不能过原始 handout：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;upx-d  decoy rc=0 out=correct!
packed decoy rc=1 out=try again~
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这说明 &lt;code&gt;upx -d&lt;/code&gt; 解出来的只有一部分是真的，完整的程序需要进一步逆向。&lt;/p&gt;
&lt;h3&gt;Step 2: 动态确认 UPX stub 改 key&lt;/h3&gt;
&lt;p&gt;既然 plain 程序接受 decoy，而 packed 程序拒绝 decoy，就应该检查 UPX stub 在跳 OEP 前有没有改写 plain ELF 的数据。plain ELF 是 non-PIE，解包后可以直接定位 verifier key 地址：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;g_stub_state.key = 0x407fa0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对原始 packed 文件下硬件 watchpoint：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ gdb -q GQuuuuuupX
(gdb) watch *(unsigned char*)0x407fa0
(gdb) run miniL{A}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次断下是 loader 把 plain 数据初始化成 &lt;code&gt;0x42&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Hardware watchpoint 1: *(unsigned char*)0x407fa0

Old value = 0 &apos;\000&apos;
New value = 66 &apos;B&apos;
0x00007ffff7ff8aee in ?? ()
0x407fa0: 0x42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;继续运行，第二次断下就是关键：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(gdb) continue
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Hardware watchpoint 1: *(unsigned char*)0x407fa0

Old value = 66 &apos;B&apos;
New value = 55 &apos;7&apos;
0x0000000000403a38 in ?? ()
0x407fa0: 0x37
rip            0x403a38            0x403a38
   0x403a30: ret
   0x403a31: syscall
   0x403a33: movb   $0x37,-0x60(%r13)
=&amp;gt; 0x403a38: pop    %rdx
   0x403a39: pop    %rax
   0x403a3a: jmp    *%rax
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，patched UPX stub 在跳回 OEP 前把 verifier key 改成了 &lt;code&gt;0x37&lt;/code&gt;。代入 profile 公式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key = 0x37
profile = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 key 不只是影响最终比较值。&lt;code&gt;decode_round_program()&lt;/code&gt;、&lt;code&gt;decode_opcode_map()&lt;/code&gt;、material slot 顺序、scratch 大小、anchor mask 和 rolling state 都依赖 key/profile，所以不能从 decoy 简单替换字符串。&lt;/p&gt;
&lt;h3&gt;解法一：逆向算法&lt;/h3&gt;
&lt;p&gt;虽然检验算法看起来很复杂，但现在正好是大模型时代，选择一个足够聪明的 LLM，把 &lt;code&gt;upx -d&lt;/code&gt; 的结果丢给他，不断&lt;s&gt;拷打&lt;/s&gt;询问即可获得一个按照步骤逆向的脚本，你只需要把LLM的脚本里的数据替换为刚刚分析出来的正确的数据。具体可以参考源文件里的 recover.py&lt;/p&gt;
&lt;h3&gt;解法二：GDB 爆破&lt;/h3&gt;
&lt;p&gt;找到流式比较处，使用 gdb/Frida Hook 等手段获得程序计算出的值，一位位枚举输入即可爆破 flag。&lt;/p&gt;
&lt;p&gt;这里给一个 gdb 脚本的例子（感谢 Starfall Koi / Radiant LyCn 的🔨）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import gdb

charset = &quot;0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_&quot;
known_flag = &quot;&quot;

gdb.execute(&quot;set pagination off&quot;)
gdb.execute(&quot;break *0x403650&quot;)

for i in range(103):
    for c in charset:
        test_input = known_flag + c + &quot;R&quot; * (102 - i)

        with open(&quot;input.txt&quot;, &quot;w&quot;) as f:
            f.write(&quot;miniL{&quot; + test_input + &quot;}\n&quot;)

        gdb.execute(&quot;run &amp;lt; input.txt&quot;)

        for _ in range(i):
            gdb.execute(&quot;continue&quot;)

        rax_val = int(gdb.parse_and_eval(&quot;$rax&quot;))

        if (rax_val &amp;amp; 0xFF) == 0:
            print(f&quot;[+] The {i}st/nd/th char is {c}&quot;)
            known_flag += c
            print(f&quot;Current Flag is {known_flag}&quot;)
            break

print(&quot;FINAL FLAG: miniL{&quot; + known_flag + &quot;}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flag: &lt;code&gt;miniL{HELLO_FROM_THE_OTHER_SIDE_IMUSTVE_CALLED_THOUSAND_TIMES_TO_TELL_YOU_IM_SORRY_FOR_EVERYTHING_THAT_I_DONE}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;local music&lt;/h2&gt;
&lt;p&gt;题目给出一个 &lt;code&gt;wyy&lt;/code&gt; 二进制文件和 &lt;code&gt;flag.enc&lt;/code&gt; 加密容器。逆向 &lt;code&gt;wyy&lt;/code&gt; 发现其将 mp3 音频用 AES-128-ECB + 魔改 RC4 加密打包，密钥由固定字符串与文件修改时间的 SHA256 派生，且合法时间戳范围在编译时固化。爆破时间戳解密得到 mp3 音频后，根据 ID3 标签中的提示 &quot;Do you know FFT?&quot;，查看频谱图即可在末尾段看到 flag。&lt;/p&gt;
&lt;h3&gt;Step 1: 逆向 wyy，理解加密逻辑&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;wyy&lt;/code&gt; 是一个 Rust 编译的二进制，通过 &lt;code&gt;strings&lt;/code&gt; 和逆向分析可以还原其加密流程：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;密钥派生&lt;/strong&gt;：&lt;code&gt;SHA256(&quot;KaguyaIrohaYachiyo&quot; + timestamp_string)&lt;/code&gt;，前 16 字节为 &lt;code&gt;core_key&lt;/code&gt;，后 16 字节为 &lt;code&gt;meta_key&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;时间戳约束&lt;/strong&gt;：&lt;code&gt;build.rs&lt;/code&gt; 在编译时将时间戳的合法范围写入 &lt;code&gt;build_consts.rs&lt;/code&gt;，范围是 &lt;code&gt;(编译时间/10000) ± 20000&lt;/code&gt; 个桶（每桶 10000 秒）。运行时 &lt;code&gt;derive_keys()&lt;/code&gt; 内有 &lt;code&gt;assert!&lt;/code&gt; 保护，超出范围直接 panic。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;容器结构&lt;/strong&gt;：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;HEADER: 10 字节 &quot;MINILCTF\0\0&quot;
key_frame:  [4 字节 LE 长度] [AES-ECB(KEY_PREFIX + audio_key) ⊕ 0x64]
meta_frame: [4 字节 LE 长度] [COMMENT_PREFIX + base64(AES-ECB(META_PREFIX + json)), 整体逐字节 ⊕ 0x63]
5 字节零填充
image_offset: 4 字节 LE
image_data
audio_data: 与 NcmRc4 流密钥 XOR 加密
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;音频加密&lt;/strong&gt;：魔改 RC4，KSA 为标准实现，PRGA 的索引计算为 &lt;code&gt;box[(box[j] + box[(box[j] + j) &amp;amp; 0xFF]) &amp;amp; 0xFF]&lt;/code&gt;。64 字节 audio_key 由 16 字节 core_key 经 4 轮 rotate/add/xor 扩展得到。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Step 2: 爆破时间戳，解密容器&lt;/h3&gt;
&lt;p&gt;根据之前的分析，时间戳的范围由 assert 限制，直接枚举时间戳即可。
对于每个候选时间戳：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;计算 &lt;code&gt;SHA256(&quot;KaguyaIrohaYachiyo&quot; + ts)&lt;/code&gt; 得到 &lt;code&gt;core_key&lt;/code&gt; 和 &lt;code&gt;meta_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;解析 &lt;code&gt;key_frame&lt;/code&gt;：逐字节 xor 0x64 后 AES-128-ECB 解密，去掉 &lt;code&gt;KEY_PREFIX&lt;/code&gt; 得到 &lt;code&gt;audio_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;解析 &lt;code&gt;meta_frame&lt;/code&gt;：逐字节 xor 0x63 后取 &lt;code&gt;COMMENT_PREFIX&lt;/code&gt; 之后部分 base64 解码，AES-128-ECB 解密得到元数据 JSON&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;audio_key&lt;/code&gt; 初始化 NcmRc4，XOR 解密音频数据&lt;/li&gt;
&lt;li&gt;校验：解密后音频以 &lt;code&gt;fLaC&lt;/code&gt; 或 &lt;code&gt;ID3&lt;/code&gt;/&lt;code&gt;0xFF 0xFB&lt;/code&gt; 开头即为成功&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;完整解题脚本：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
from __future__ import annotations

import argparse
import base64
import hashlib
import shutil
import subprocess
from pathlib import Path


HEADER = b&quot;MINILCTF\x00\x00&quot;
KEY_PREFIX = b&quot;miniL-audio-key&quot;
META_PREFIX = b&quot;miniL:&quot;
COMMENT_PREFIX = b&quot;miniL meta:&quot;
KEY_SEED_PREFIX = b&quot;KaguyaIrohaYachiyo&quot;


def main() -&amp;gt; None:
    args = parse_args()
    if shutil.which(&quot;openssl&quot;) is None:
        raise SystemExit(&quot;openssl not found in PATH&quot;)

    data = args.input.read_bytes()
    if not data.startswith(HEADER):
        raise SystemExit(&quot;bad container header&quot;)

    base_ts = args.timestamp or int(args.input.stat().st_mtime)
    ts, audio, meta, image = try_decrypt(data, base_ts, max(args.search, 0))

    out_dir = args.output_dir or args.input.parent
    out_dir.mkdir(parents=True, exist_ok=True)
    stem = args.stem or args.input.stem

    # 写入解密后的音频（题目中为 mp3 格式）
    if audio.startswith(b&quot;fLaC&quot;):
        audio_ext = &quot;flac&quot;
    elif audio.startswith(b&quot;ID3&quot;) or audio[:2] == b&quot;\xFF\xFB&quot;:
        audio_ext = &quot;mp3&quot;
    else:
        audio_ext = &quot;bin&quot;
    audio_path = out_dir / f&quot;{stem}.{audio_ext}&quot;
    audio_path.write_bytes(audio)
    print(f&quot;[+] decrypted audio → {audio_path}&quot;)

    # 写入元数据
    meta_path = out_dir / f&quot;{stem}.json&quot;
    meta_path.write_bytes(meta)
    print(f&quot;[+] metadata → {meta_path}&quot;)

    # 写入封面图片
    if image:
        ext = &quot;png&quot; if image.startswith(b&quot;\x89PNG&quot;) else &quot;jpg&quot;
        img_path = out_dir / f&quot;{stem}.{ext}&quot;
        img_path.write_bytes(image)
        print(f&quot;[+] cover image → {img_path}&quot;)

    print(f&quot;[*] timestamp = {ts}&quot;)
    # ID3 标签中有提示 &quot;Do you know FFT?&quot;，指向频谱图
    print(f&quot;[*] 用 Audacity/Sonic Visualiser 查看 {audio_path} 的频谱图即可看到 flag&quot;)


def parse_args() -&amp;gt; argparse.Namespace:
    parser = argparse.ArgumentParser(description=&quot;wyy challenge solver&quot;)
    parser.add_argument(&quot;input&quot;, nargs=&quot;?&quot;, type=Path, default=Path(&quot;flag.enc&quot;))
    parser.add_argument(&quot;--timestamp&quot;, type=int, help=&quot;手动指定时间戳&quot;)
    parser.add_argument(&quot;--search&quot;, type=int, default=10000, help=&quot;爆破半径（秒）&quot;)
    parser.add_argument(&quot;--output-dir&quot;, type=Path, help=&quot;输出目录&quot;)
    parser.add_argument(&quot;--stem&quot;, help=&quot;输出文件名前缀&quot;)
    return parser.parse_args()


def try_decrypt(data, base_ts, radius):
    candidates = [base_ts]
    for delta in range(1, radius + 1):
        candidates.append(base_ts + delta)
        candidates.append(base_ts - delta)

    for ts in candidates:
        core_key, meta_key = derive_keys(ts)
        try:
            audio, meta, image = decrypt(data, core_key, meta_key)
            return ts, audio, meta, image
        except Exception:
            continue
    raise SystemExit(f&quot;failed to decrypt around ts={base_ts} +/-{radius}s&quot;)


def derive_keys(ts):
    digest = hashlib.sha256(KEY_SEED_PREFIX + str(ts).encode()).digest()
    return digest[:16], digest[16:]


def decrypt(data, core_key, meta_key):
    pos = len(HEADER)

    # 解密 key frame → audio_key
    key_frame, pos = read_frame(data, pos)
    key_frame = bytes(b ^ 0x64 for b in key_frame)
    plain = aes128_ecb_decrypt(key_frame, core_key)
    if not plain.startswith(KEY_PREFIX):
        raise ValueError(&quot;bad key frame&quot;)
    audio_key = plain[len(KEY_PREFIX):]

    # 解密 meta frame
    meta_frame, pos = read_frame(data, pos)
    meta_frame = bytes(b ^ 0x63 for b in meta_frame)
    if not meta_frame.startswith(COMMENT_PREFIX):
        raise ValueError(&quot;bad meta prefix&quot;)
    payload = base64.b64decode(meta_frame[len(COMMENT_PREFIX):])
    meta_plain = aes128_ecb_decrypt(payload, meta_key)
    if not meta_plain.startswith(META_PREFIX):
        raise ValueError(&quot;bad meta&quot;)
    meta = meta_plain[len(META_PREFIX):]

    # 跳过 5 字节零 + image_offset + image_data
    pos += 5
    image_offset = int.from_bytes(data[pos:pos + 4], &quot;little&quot;)
    pos += 4
    image, pos = read_frame(data, pos)
    if image_offset &amp;gt; len(image):
        pos += image_offset - len(image)

    # 解密音频
    audio = xor_audio(data[pos:], audio_key)
    return audio, meta, image


def aes128_ecb_decrypt(data, key):
    proc = subprocess.run(
        [&quot;openssl&quot;, &quot;enc&quot;, &quot;-aes-128-ecb&quot;, &quot;-d&quot;, &quot;-nopad&quot;, &quot;-nosalt&quot;, &quot;-K&quot;, key.hex()],
        input=data, check=True, capture_output=True,
    )
    result = proc.stdout
    # PKCS7 unpad
    pad = result[-1]
    if pad == 0 or pad &amp;gt; 16 or result[-pad:] != bytes([pad]) * pad:
        raise ValueError(&quot;bad pkcs7&quot;)
    return result[:-pad]


def xor_audio(data, key):
    box = list(range(256))
    j = 0
    for i in range(256):
        j = (box[i] + j + key[i % len(key)]) &amp;amp; 0xFF
        box[i], box[j] = box[j], box[i]

    plain = bytearray(data)
    for i in range(len(plain)):
        j = (i + 1) &amp;amp; 0xFF
        plain[i] ^= box[(box[j] + box[(box[j] + j) &amp;amp; 0xFF]) &amp;amp; 0xFF]
    return bytes(plain)


def read_frame(data, pos):
    size = int.from_bytes(data[pos:pos + 4], &quot;little&quot;)
    return data[pos + 4:pos + 4 + size], pos + 4 + size


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Step 3: 查看频谱图&lt;/h3&gt;
&lt;p&gt;解密得到的 mp3 文件用 Audacity 打开，切换到频谱图（Spectrogram）视图，在音频末尾段可见 flag 文本。&lt;/p&gt;
&lt;p&gt;也可以用 Python 直接渲染（需要先将 mp3 转为 wav）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import subprocess
from pathlib import Path
import numpy as np
from scipy.io import wavfile
from scipy.signal import stft
from PIL import Image

def mp3_to_wav(mp3_path):
    wav_path = mp3_path.with_suffix(&quot;.wav&quot;)
    subprocess.run(
        [&quot;ffmpeg&quot;, &quot;-v&quot;, &quot;error&quot;, &quot;-i&quot;, str(mp3_path), &quot;-ar&quot;, &quot;48000&quot;, &quot;-ac&quot;, &quot;2&quot;, str(wav_path)],
        check=True,
    )
    return wav_path

wav = mp3_to_wav(Path(&quot;flag.mp3&quot;))
sr, audio = wavfile.read(str(wav))
signal = audio[:, 0].astype(np.float32)
_, _, Z = stft(signal, fs=sr, window=&quot;hann&quot;, nperseg=4096, noverlap=3840)
spec = np.abs(Z[:, -8000:])  # flag 在末尾前约 38s~18s 的位置
spec_db = 20 * np.log10(np.clip(spec, 1e-10, None))
img = np.clip((spec_db + 80) / 80 * 255, 0, 255).astype(np.uint8)
Image.fromarray(np.flipud(img)).save(&quot;spectrogram.png&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flag: &lt;code&gt;miniL{Yur1_1s_JusT1c3!!F0ll0w_Ch0u-K@guy@-h1me!th@nks_m03w}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;As the birds say&lt;/h2&gt;
&lt;p&gt;Wabun Code + Morse Code 描述了一段 flag，需要解码内容然后还原。&lt;/p&gt;
&lt;h3&gt;Step 1&lt;/h3&gt;
&lt;p&gt;观察可以发现有3种声音，不妨按照第一次出现的顺序标记为012，可以发现没有连续的22出现，推测2是分割符，进一步根据间隔不一，猜测是 Morse Code。&lt;/p&gt;
&lt;h3&gt;Step 2&lt;/h3&gt;
&lt;p&gt;由于三种声音长度各不相同，让AI写一个简单的枚举长度+贪心匹配，可以得到下面的序列。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-.. --- .- ..-. .-.-.- --.. ... ...-.. -... ... -. -- .. -. .. .-.. -.. --- .-.--.. -... --.-... -..- --. .-.-.- .-. .-
-- ..- -... ..-. -..-- ..- .-.. .--. ---- .-.--.. .-.. ---- -..- --- .-.-- .- -..- ---.- .-.-.. -.-. .-.-.- --.. ... ...
-.. ..-- .-. .- -- ..- -... ... -. .-- .- -... ..- -. -.-. --- -.. . .. ... ... --- ..- ... . .-.. . ... ... -.. --- .-.
--.. ---.- .-.-.. -.-.- .-.-. .-.-.- -. .-.-. ----.. -... --.-- .-.-. -... .--.- ---.- ---- --.-- .-.--.. .--. .-. .-...
. --- .-.-- .- -..- ---.- .-.-.. -- .-.-. .-.-.- -.-.- .- --.-. -- ..-- -. .-.-. ----.. -... .-... .-... -..-. --.-... .
-.--.. -... --.-... -..- --. -..- ---.- .-.-.. ----.. .-.-.- --.. ... ...-.. ..-. -..-- ..- ..-- -.--- .--.- .--- --.--
.--. ..-.. -..- .--.- ...- -.-. .-.-.- .-... .--.- .--- .---... .-.- -.-. .-.-.- .- .--.- .--- -.-.- .-.-. -.-. .-.-.- -
-.-- .- .--- .- ..-. -.-. .-... -.-.. .-.. -.--- .-.-- ...- -... -.-.- .- .-.-.. ... -.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意有几处静音段，这里其实是在表示flag content里词与词之间的间隔。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/image-20260506224241793.png&quot; alt=&quot;Morse code sequence visualization&quot; /&gt;&lt;/p&gt;
&lt;p&gt;用 CyberChef From Morse Code 解码得到下面的结果&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./assets/image-20260507001531646.png&quot; alt=&quot;CyberChef Morse decode result&quot; /&gt;&lt;/p&gt;
&lt;p&gt;需要注意到其中有 MINIL，说明我们的方向正确。再看到 WABUNCODEISUSELESS，进一步搜索 WABUN CODE 可以知道这里的乱码其实是和文码，开头的 DO 和结尾的 SN 正是切换语言的标志。&lt;/p&gt;
&lt;h3&gt;Step 3&lt;/h3&gt;
&lt;p&gt;使用在线网站 https://www.dcode.fr/wabun-code 或直接 Ask LLM，可以恢复出原文：&lt;/p&gt;
&lt;p&gt;&quot;イチ、フラグハ [miniL] デハジマリ、ナイヨウハチュウカッコデカコマレテイマス。ニ、フラグノナイヨウハ [wabun code is so useless] デス。サン、タンゴハアンダースコアデツナガレテイマス。ヨン、サイショノタンゴハオオモジデハジマリマス。ゴ、フラグチュウノエーヲアットマークニ、オーヲゼロニ、イーヲサンニ、アイヲイチニオキカエテクダサイ。&quot;&lt;/p&gt;
&lt;p&gt;找个网站翻译成英语得到&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The flag begins with [miniL], and its content is enclosed in curly braces.&lt;/li&gt;
&lt;li&gt;The content of the flag is [wabun code is so useless].&lt;/li&gt;
&lt;li&gt;Words are connected by underscores.&lt;/li&gt;
&lt;li&gt;The first word begins with a capital letter.&lt;/li&gt;
&lt;li&gt;Please replace the &apos;A&apos;s in the flag with &apos;@&apos;, the &apos;O&apos;s with &apos;0&apos;, the &apos;E&apos;s with &apos;3&apos;, and the &apos;I&apos;s with &apos;1&apos;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;按照说明一步步做得到结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;miniL{W@bun_c0d3_1s_s0_us3l3ss}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>New Year CTF 2026 RE Writeup</title><link>https://sandt3a.github.io/posts/en/new-year-ctf-2026-re-wp/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/en/new-year-ctf-2026-re-wp/</guid><description>Challenges I wrote for a small New Year CTF.</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wrote two reverse engineering challenges for a small New Year CTF.&lt;/p&gt;
&lt;h2&gt;nonogram&lt;/h2&gt;
&lt;p&gt;The core idea was to encode the flag as a nonogram. To force a unique solution, I added a &lt;code&gt;crc32&lt;/code&gt; check. It feels a bit pointless, but I did not know a better way to control uniqueness.&lt;/p&gt;
&lt;p&gt;There is also a small trap: the nonogram clues are loaded in &lt;code&gt;.init&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
import argparse
import subprocess
from functools import lru_cache


WIDTH = 0x113  # 275
HEIGHT = 5
BIT_BUDGET = 0xFE5  # 4069 bits consumed by verifier

# Derived from reversing nonogram_gate
CIPHER_FILE_OFFSET = 0x2020
CIPHER_LEN = 0x1FD  # 509
XOR_STRIDE = 0x3D


def extract_cipher(path: str) -&amp;gt; bytes:
    with open(path, &quot;rb&quot;) as f:
        blob = f.read()
    end = CIPHER_FILE_OFFSET + CIPHER_LEN
    if end &amp;gt; len(blob):
        raise ValueError(&quot;binary too small for expected encrypted clue region&quot;)
    return blob[CIPHER_FILE_OFFSET:end]


def decode_clue_blob(cipher: bytes) -&amp;gt; bytes:
    tmp = bytearray(CIPHER_LEN)
    out = bytearray(CIPHER_LEN)
    for i, c in enumerate(cipher):
        tmp[i] = (c ^ ((i * XOR_STRIDE - 0x0D) &amp;amp; 0xFF)) &amp;amp; 0xFF
    for i, v in enumerate(tmp):
        out[(i * 7) % CIPHER_LEN] = v
    return bytes(out)


class BitReader:
    def __init__(self, data: bytes):
        self.data = data
        self.bitpos = 0

    def read(self, nbits: int) -&amp;gt; int:
        val = 0
        for _ in range(nbits):
            if self.bitpos &amp;gt;= len(self.data) * 8:
                raise ValueError(&quot;bitstream underflow&quot;)
            byte = self.data[self.bitpos // 8]
            shift = 7 - (self.bitpos % 8)
            bit = (byte &amp;gt;&amp;gt; shift) &amp;amp; 1
            val = (val &amp;lt;&amp;lt; 1) | bit
            self.bitpos += 1
        return val


def parse_clues(decoded: bytes):
    br = BitReader(decoded)
    row_clues = []
    for _ in range(HEIGHT):
        cnt = br.read(7)
        row_clues.append([br.read(3) for _ in range(cnt)])

    col_clues = []
    for _ in range(WIDTH):
        cnt = br.read(7)
        col_clues.append([br.read(3) for _ in range(cnt)])

    if br.bitpos != BIT_BUDGET:
        raise ValueError(f&quot;unexpected clue bit count: {br.bitpos} != {BIT_BUDGET}&quot;)
    return row_clues, col_clues


def runs_from_bits(bits):
    runs = []
    run = 0
    for b in bits:
        if b:
            run += 1
        elif run:
            runs.append(run)
            run = 0
    if run:
        runs.append(run)
    return runs


def column_candidates(col_clues):
    cand = []
    for clue in col_clues:
        valid = []
        for mask in range(1 &amp;lt;&amp;lt; HEIGHT):
            bits = [1 if (mask &amp;gt;&amp;gt; r) &amp;amp; 1 else 0 for r in range(HEIGHT)]
            if runs_from_bits(bits) == clue:
                valid.append(bits)
        if not valid:
            raise ValueError(f&quot;no candidate column for clue {clue}&quot;)
        cand.append(valid)
    return cand


def row_transition_table(row_clues):
    tables = []
    starts = []
    accepts = []
    for runs in row_clues:
        k = len(runs)
        state_id = {}
        states = []
        trans = []

        def intern(st):
            if st in state_id:
                return state_id[st]
            idx = len(states)
            state_id[st] = idx
            states.append(st)
            trans.append([-1, -1])
            return idx

        start = intern((0, 0, 0))  # (next_run_idx, rem_in_current_run, must_gap_zero)
        q = [start]
        qi = 0
        while qi &amp;lt; len(q):
            sid = q[qi]
            qi += 1
            run_idx, rem, must_gap = states[sid]
            for bit in (0, 1):
                nxt = None
                if rem &amp;gt; 0:
                    if bit == 1:
                        rem2 = rem - 1
                        if rem2 == 0:
                            if run_idx == k - 1:
                                nxt = (k, 0, 0)
                            else:
                                nxt = (run_idx + 1, 0, 1)
                        else:
                            nxt = (run_idx, rem2, 0)
                else:
                    if run_idx == k:
                        if bit == 0:
                            nxt = (k, 0, 0)
                    else:
                        if must_gap:
                            if bit == 0:
                                nxt = (run_idx, 0, 0)
                        else:
                            if bit == 0:
                                nxt = (run_idx, 0, 0)
                            else:
                                rem2 = runs[run_idx] - 1
                                if rem2 == 0:
                                    if run_idx == k - 1:
                                        nxt = (k, 0, 0)
                                    else:
                                        nxt = (run_idx + 1, 0, 1)
                                else:
                                    nxt = (run_idx, rem2, 0)
                if nxt is not None:
                    nid = intern(nxt)
                    trans[sid][bit] = nid
                    if nid &amp;gt;= len(q):
                        q.append(nid)

        accept_set = {sid for sid, (ri, rem, _) in enumerate(states) if ri == k and rem == 0}
        tables.append(trans)
        starts.append(start)
        accepts.append(accept_set)
    return tables, starts, accepts


def solve_board(row_clues, col_clues):
    col_cands = column_candidates(col_clues)
    trans, starts, accepts = row_transition_table(row_clues)

    @lru_cache(maxsize=None)
    def dfs(col, s0, s1, s2, s3, s4):
        states = (s0, s1, s2, s3, s4)
        if col == WIDTH:
            ok = all(states[r] in accepts[r] for r in range(HEIGHT))
            return tuple() if ok else None

        for bits in col_cands[col]:
            nxt = []
            valid = True
            for r in range(HEIGHT):
                ns = trans[r][states[r]][bits[r]]
                if ns &amp;lt; 0:
                    valid = False
                    break
                nxt.append(ns)
            if not valid:
                continue
            tail = dfs(col + 1, nxt[0], nxt[1], nxt[2], nxt[3], nxt[4])
            if tail is not None:
                return (tuple(bits),) + tail
        return None

    path = dfs(0, starts[0], starts[1], starts[2], starts[3], starts[4])
    if path is None:
        raise ValueError(&quot;no satisfying board found&quot;)

    board = [[0] * WIDTH for _ in range(HEIGHT)]
    for c, col_bits in enumerate(path):
        for r in range(HEIGHT):
            board[r][c] = col_bits[r]
    return board


def board_to_submission_hex(board):
    bits = []
    for r in range(HEIGHT):
        bits.extend(board[r])
    bits.append(0)  # parser expects one extra trailing bit from 344th nibble; must be zero

    if len(bits) != 0x560:  # 1376
        raise ValueError(&quot;unexpected bit length for hex encoding&quot;)

    out = []
    for i in range(0, len(bits), 4):
        nib = (bits[i] &amp;lt;&amp;lt; 3) | (bits[i + 1] &amp;lt;&amp;lt; 2) | (bits[i + 2] &amp;lt;&amp;lt; 1) | bits[i + 3]
        out.append(format(nib, &quot;x&quot;))
    return &quot;&quot;.join(out)


def pretty(board):
    return &quot;\n&quot;.join(&quot;&quot;.join(&quot;#&quot; if board[r][c] else &quot;.&quot; for c in range(WIDTH)) for r in range(HEIGHT))


def main():
    ap = argparse.ArgumentParser(description=&quot;Solve nonogram_gate by decoding and solving its nonogram clues.&quot;)
    ap.add_argument(&quot;binary&quot;, nargs=&quot;?&quot;, default=&quot;./nonogram_gate&quot;, help=&quot;path to challenge binary&quot;)
    ap.add_argument(&quot;--run&quot;, action=&quot;store_true&quot;, help=&quot;run the binary with computed hex&quot;)
    args = ap.parse_args()

    cipher = extract_cipher(args.binary)
    decoded = decode_clue_blob(cipher)
    row_clues, col_clues = parse_clues(decoded)
    board = solve_board(row_clues, col_clues)
    answer_hex = board_to_submission_hex(board)

    print(answer_hex)
    print()
    print(pretty(board))

    if args.run:
        p = subprocess.run(
            [args.binary],
            input=(answer_hex + &quot;\n&quot;).encode(),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            check=False,
        )
        print()
        print(p.stdout.decode(errors=&quot;replace&quot;))


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;starless_c&lt;/h2&gt;
&lt;p&gt;I mean it, this is basically the &lt;a href=&quot;https://github.com/uclaacm/lactf-archive/tree/main/2026/rev/starless-c&quot;&gt;original challenge&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The underlying logic is just Sokoban with WASD movement and &lt;code&gt;F&lt;/code&gt; for checking the result, except here you are pushing memory blocks. Since there are only a few boxes, a bitmask BFS solves it very quickly. I was lazy here, so I just pasted the author&apos;s original source code.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from copy import deepcopy

puzzle = &quot;&quot;&quot;
########
#..#####
#.....##
#oo.oo##
#..#o..#
#......#
########
&quot;&quot;&quot;.strip().split(&quot;\n&quot;)
puzzle = [list(x) for x in puzzle]

pos = (1, 1)

inp = &quot;&quot;
history = []
while True:
    print(inp)
    for (i, r) in enumerate(puzzle):
        for (j, v) in enumerate(r):
            x = v
            if (i, j) == pos:
                x = &quot;x&quot;
            print(x, end=&quot;&quot;)
        print()
    for c in input(&quot;&amp;gt; &quot;):
        if c == &quot;z&quot;:
            (puzzle, pos, inp) = history.pop()
            continue
        elif c in (&quot;w&quot;, &quot;a&quot;, &quot;s&quot;, &quot;d&quot;):
            (dr, dc) = {&quot;w&quot;: (-1, 0), &quot;s&quot;: (1, 0), &quot;a&quot;: (0, -1), &quot;d&quot;: (0, 1)}[c]
            (nr, nc) = (pos[0] + dr, pos[1] + dc)
            hist = (deepcopy(puzzle), pos, inp)
            if puzzle[nr][nc] == &quot;#&quot;:
                continue
            if puzzle[nr][nc] == &quot;o&quot;:
                (nnr, nnc) = (nr + dr, nc + dc)
                if puzzle[nnr][nnc] != &quot;.&quot;:
                    continue
                puzzle[nnr][nnc] = &quot;o&quot;
                puzzle[nr][nc] = &quot;.&quot;
            pos = (nr, nc)
            history.append(hist)
            inp += c
        else:
            print(&quot;unknown&quot;)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>New Year CTF 2026 逆向WP</title><link>https://sandt3a.github.io/posts/zh-cn/new-year-ctf-2026-re-wp/</link><guid isPermaLink="true">https://sandt3a.github.io/posts/zh-cn/new-year-ctf-2026-re-wp/</guid><description>给新年小比赛出的题</description><pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;给XDSEC的新年小比赛出了两道逆向题&lt;/p&gt;
&lt;h2&gt;nonogram&lt;/h2&gt;
&lt;p&gt;底层逻辑是把flag画成了数织，为了保证解唯一加了个crc32的check（感觉很意义不明，但是不知道怎么控制解唯一）&lt;/p&gt;
&lt;p&gt;埋了个坑，让数织的线索在.init里加载。下面是AI写的解题脚本，把数织画出来之后OCR或者用人眼看。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
import argparse
import subprocess
from functools import lru_cache


WIDTH = 0x113  # 275
HEIGHT = 5
BIT_BUDGET = 0xFE5  # 4069 bits consumed by verifier

# Derived from reversing nonogram_gate
CIPHER_FILE_OFFSET = 0x2020
CIPHER_LEN = 0x1FD  # 509
XOR_STRIDE = 0x3D


def extract_cipher(path: str) -&amp;gt; bytes:
    with open(path, &quot;rb&quot;) as f:
        blob = f.read()
    end = CIPHER_FILE_OFFSET + CIPHER_LEN
    if end &amp;gt; len(blob):
        raise ValueError(&quot;binary too small for expected encrypted clue region&quot;)
    return blob[CIPHER_FILE_OFFSET:end]


def decode_clue_blob(cipher: bytes) -&amp;gt; bytes:
    tmp = bytearray(CIPHER_LEN)
    out = bytearray(CIPHER_LEN)
    for i, c in enumerate(cipher):
        tmp[i] = (c ^ ((i * XOR_STRIDE - 0x0D) &amp;amp; 0xFF)) &amp;amp; 0xFF
    for i, v in enumerate(tmp):
        out[(i * 7) % CIPHER_LEN] = v
    return bytes(out)


class BitReader:
    def __init__(self, data: bytes):
        self.data = data
        self.bitpos = 0

    def read(self, nbits: int) -&amp;gt; int:
        val = 0
        for _ in range(nbits):
            if self.bitpos &amp;gt;= len(self.data) * 8:
                raise ValueError(&quot;bitstream underflow&quot;)
            byte = self.data[self.bitpos // 8]
            shift = 7 - (self.bitpos % 8)
            bit = (byte &amp;gt;&amp;gt; shift) &amp;amp; 1
            val = (val &amp;lt;&amp;lt; 1) | bit
            self.bitpos += 1
        return val


def parse_clues(decoded: bytes):
    br = BitReader(decoded)
    row_clues = []
    for _ in range(HEIGHT):
        cnt = br.read(7)
        row_clues.append([br.read(3) for _ in range(cnt)])

    col_clues = []
    for _ in range(WIDTH):
        cnt = br.read(7)
        col_clues.append([br.read(3) for _ in range(cnt)])

    if br.bitpos != BIT_BUDGET:
        raise ValueError(f&quot;unexpected clue bit count: {br.bitpos} != {BIT_BUDGET}&quot;)
    return row_clues, col_clues


def runs_from_bits(bits):
    runs = []
    run = 0
    for b in bits:
        if b:
            run += 1
        elif run:
            runs.append(run)
            run = 0
    if run:
        runs.append(run)
    return runs


def column_candidates(col_clues):
    cand = []
    for clue in col_clues:
        valid = []
        for mask in range(1 &amp;lt;&amp;lt; HEIGHT):
            bits = [1 if (mask &amp;gt;&amp;gt; r) &amp;amp; 1 else 0 for r in range(HEIGHT)]
            if runs_from_bits(bits) == clue:
                valid.append(bits)
        if not valid:
            raise ValueError(f&quot;no candidate column for clue {clue}&quot;)
        cand.append(valid)
    return cand


def row_transition_table(row_clues):
    tables = []
    starts = []
    accepts = []
    for runs in row_clues:
        k = len(runs)
        state_id = {}
        states = []
        trans = []

        def intern(st):
            if st in state_id:
                return state_id[st]
            idx = len(states)
            state_id[st] = idx
            states.append(st)
            trans.append([-1, -1])
            return idx

        start = intern((0, 0, 0))  # (next_run_idx, rem_in_current_run, must_gap_zero)
        q = [start]
        qi = 0
        while qi &amp;lt; len(q):
            sid = q[qi]
            qi += 1
            run_idx, rem, must_gap = states[sid]
            for bit in (0, 1):
                nxt = None
                if rem &amp;gt; 0:
                    if bit == 1:
                        rem2 = rem - 1
                        if rem2 == 0:
                            if run_idx == k - 1:
                                nxt = (k, 0, 0)
                            else:
                                nxt = (run_idx + 1, 0, 1)
                        else:
                            nxt = (run_idx, rem2, 0)
                else:
                    if run_idx == k:
                        if bit == 0:
                            nxt = (k, 0, 0)
                    else:
                        if must_gap:
                            if bit == 0:
                                nxt = (run_idx, 0, 0)
                        else:
                            if bit == 0:
                                nxt = (run_idx, 0, 0)
                            else:
                                rem2 = runs[run_idx] - 1
                                if rem2 == 0:
                                    if run_idx == k - 1:
                                        nxt = (k, 0, 0)
                                    else:
                                        nxt = (run_idx + 1, 0, 1)
                                else:
                                    nxt = (run_idx, rem2, 0)
                if nxt is not None:
                    nid = intern(nxt)
                    trans[sid][bit] = nid
                    if nid &amp;gt;= len(q):
                        q.append(nid)

        accept_set = {sid for sid, (ri, rem, _) in enumerate(states) if ri == k and rem == 0}
        tables.append(trans)
        starts.append(start)
        accepts.append(accept_set)
    return tables, starts, accepts


def solve_board(row_clues, col_clues):
    col_cands = column_candidates(col_clues)
    trans, starts, accepts = row_transition_table(row_clues)

    @lru_cache(maxsize=None)
    def dfs(col, s0, s1, s2, s3, s4):
        states = (s0, s1, s2, s3, s4)
        if col == WIDTH:
            ok = all(states[r] in accepts[r] for r in range(HEIGHT))
            return tuple() if ok else None

        for bits in col_cands[col]:
            nxt = []
            valid = True
            for r in range(HEIGHT):
                ns = trans[r][states[r]][bits[r]]
                if ns &amp;lt; 0:
                    valid = False
                    break
                nxt.append(ns)
            if not valid:
                continue
            tail = dfs(col + 1, nxt[0], nxt[1], nxt[2], nxt[3], nxt[4])
            if tail is not None:
                return (tuple(bits),) + tail
        return None

    path = dfs(0, starts[0], starts[1], starts[2], starts[3], starts[4])
    if path is None:
        raise ValueError(&quot;no satisfying board found&quot;)

    board = [[0] * WIDTH for _ in range(HEIGHT)]
    for c, col_bits in enumerate(path):
        for r in range(HEIGHT):
            board[r][c] = col_bits[r]
    return board


def board_to_submission_hex(board):
    bits = []
    for r in range(HEIGHT):
        bits.extend(board[r])
    bits.append(0)  # parser expects one extra trailing bit from 344th nibble; must be zero

    if len(bits) != 0x560:  # 1376
        raise ValueError(&quot;unexpected bit length for hex encoding&quot;)

    out = []
    for i in range(0, len(bits), 4):
        nib = (bits[i] &amp;lt;&amp;lt; 3) | (bits[i + 1] &amp;lt;&amp;lt; 2) | (bits[i + 2] &amp;lt;&amp;lt; 1) | bits[i + 3]
        out.append(format(nib, &quot;x&quot;))
    return &quot;&quot;.join(out)


def pretty(board):
    return &quot;\n&quot;.join(&quot;&quot;.join(&quot;#&quot; if board[r][c] else &quot;.&quot; for c in range(WIDTH)) for r in range(HEIGHT))


def main():
    ap = argparse.ArgumentParser(description=&quot;Solve nonogram_gate by decoding and solving its nonogram clues.&quot;)
    ap.add_argument(&quot;binary&quot;, nargs=&quot;?&quot;, default=&quot;./nonogram_gate&quot;, help=&quot;path to challenge binary&quot;)
    ap.add_argument(&quot;--run&quot;, action=&quot;store_true&quot;, help=&quot;run the binary with computed hex&quot;)
    args = ap.parse_args()

    cipher = extract_cipher(args.binary)
    decoded = decode_clue_blob(cipher)
    row_clues, col_clues = parse_clues(decoded)
    board = solve_board(row_clues, col_clues)
    answer_hex = board_to_submission_hex(board)

    print(answer_hex)
    print()
    print(pretty(board))

    if args.run:
        p = subprocess.run(
            [args.binary],
            input=(answer_hex + &quot;\n&quot;).encode(),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            check=False,
        )
        print()
        print(p.stdout.decode(errors=&quot;replace&quot;))


if __name__ == &quot;__main__&quot;:
    main()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;starless_c&lt;/h2&gt;
&lt;p&gt;我说这是&lt;a href=&quot;https://github.com/uclaacm/lactf-archive/tree/main/2026/rev/starless-c&quot;&gt;原题&lt;/a&gt;你耳朵🐲吗&lt;/p&gt;
&lt;p&gt;底层逻辑就是WASD推箱子，F检测结果，但是这里推的是内存块。由于箱子很少，写一个状压BFS可以很快跑出解。~我这里就偷懒直接贴出题人源码了~&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from copy import deepcopy

puzzle = &quot;&quot;&quot;
########
#..#####
#.....##
#oo.oo##
#..#o..#
#......#
########
&quot;&quot;&quot;.strip().split(&quot;\n&quot;)
puzzle = [list(x) for x in puzzle]

pos = (1, 1)

inp = &quot;&quot;
history = []
while True:
    print(inp)
    for (i, r) in enumerate(puzzle):
        for (j, v) in enumerate(r):
            x = v
            if (i, j) == pos:
                x = &quot;x&quot;
            print(x, end=&quot;&quot;)
        print()
    for c in input(&quot;&amp;gt; &quot;):
        if c == &quot;z&quot;:
            (puzzle, pos, inp) = history.pop()
            continue
        elif c in (&quot;w&quot;, &quot;a&quot;, &quot;s&quot;, &quot;d&quot;):
            (dr, dc) = {&quot;w&quot;: (-1, 0), &quot;s&quot;: (1, 0), &quot;a&quot;: (0, -1), &quot;d&quot;: (0, 1)}[c]
            (nr, nc) = (pos[0] + dr, pos[1] + dc)
            hist = (deepcopy(puzzle), pos, inp)
            if puzzle[nr][nc] == &quot;#&quot;:
                continue
            if puzzle[nr][nc] == &quot;o&quot;:
                (nnr, nnc) = (nr + dr, nc + dc)
                if puzzle[nnr][nnc] != &quot;.&quot;:
                    continue
                puzzle[nnr][nnc] = &quot;o&quot;
                puzzle[nr][nc] = &quot;.&quot;
            pos = (nr, nc)
            history.append(hist)
            inp += c
        else:
            print(&quot;unknown&quot;)
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item></channel></rss>