<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: duchu.nft</title>
    <description>The latest articles on DEV Community by duchu.nft (@duchuaz).</description>
    <link>https://dev.to/duchuaz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4148260%2F944d32b4-b432-4613-977a-dcace7a1542c.jpg</url>
      <title>DEV Community: duchu.nft</title>
      <link>https://dev.to/duchuaz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/duchuaz"/>
    <language>en</language>
    <item>
      <title>SafePlate: an Offline Allergy Meal Planner Running Local Gemma 3 (Built for My Roommate)</title>
      <dc:creator>duchu.nft</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:33:13 +0000</pubDate>
      <link>https://dev.to/duchuaz/safeplate-an-offline-allergy-meal-planner-running-local-gemma-3-built-for-my-roommate-4mll</link>
      <guid>https://dev.to/duchuaz/safeplate-an-offline-allergy-meal-planner-running-local-gemma-3-built-for-my-roommate-4mll</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SafePlate&lt;/strong&gt; — an allergy-aware meal planner for my roommate, who is allergic&lt;br&gt;
to peanuts and shrimp. He told me weeknight cooking is the worst part of his&lt;br&gt;
day: after work, standing in a noisy dorm kitchen trying to invent dinner that&lt;br&gt;
won't send him to the clinic.&lt;/p&gt;

&lt;p&gt;SafePlate takes his allergies, household size, and who he's cooking for&lt;br&gt;
(student / gym-goer / senior), then generates a 7-day Vietnamese dinner plan&lt;br&gt;
with ingredients, steps, a per-day nutrition note, and a consolidated shopping&lt;br&gt;
list. The UI is bilingual (Vietnamese / English).&lt;/p&gt;

&lt;p&gt;The whole thing runs &lt;strong&gt;on his laptop, offline&lt;/strong&gt;: open-weight &lt;strong&gt;Gemma 3 4B&lt;/strong&gt;&lt;br&gt;
served locally by llama.cpp. No accounts, no cloud, no tracking, no per-token&lt;br&gt;
bill.&lt;/p&gt;

&lt;p&gt;I handed it to him on day one. His words (translated from Vietnamese):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Pretty useful — normally I have to come up with meals myself, total headache."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;His feedback shaped v2 the same evening: &lt;em&gt;"menus need more variety — different&lt;br&gt;
kinds of people, still nutritionally balanced."&lt;/em&gt; So I added the audience&lt;br&gt;
selector and the nutrition note. That loop — real person, real complaint, shipped&lt;br&gt;
the same night — is the whole point of this challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;UI (Vietnamese mode, his actual allergies checked):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu2zx7emehq0u24zvwj4d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu2zx7emehq0u24zvwj4d.png" alt="SafePlate form" width="800" height="621"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Result generated 100% locally by Gemma 3 4B:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faghfiivkn7x46hjxqde1.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faghfiivkn7x46hjxqde1.jpg" alt="SafePlate result" width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quick walkthrough:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8vgnv3vli8nvj3labx9k.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8vgnv3vli8nvj3labx9k.gif" alt="SafePlate demo" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No deployed link — and that's deliberate: the app is designed to work with the&lt;br&gt;
wifi off. The demo above ran on a laptop in airplane mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/duchuAz/safeplate" rel="noopener noreferrer"&gt;https://github.com/duchuAz/safeplate&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Stack: one Python file serving the UI + a &lt;code&gt;/plan&lt;/code&gt; endpoint, one static HTML&lt;br&gt;
page, one prompt builder. The model backend is stock &lt;code&gt;llama-server&lt;/code&gt; with a&lt;br&gt;
local GGUF (&lt;code&gt;ggml-org/gemma-3-4b-it-Q4_K_M&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Open-source AI is not a garnish here — it &lt;em&gt;is&lt;/em&gt; the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open weights (Gemma 3 4B, Apache-style Gemma license):&lt;/strong&gt; downloaded once,
runs forever. I started on the 1B quant for speed, then swapped to 4B by
changing one file path. Try doing that with a closed API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open runtime (llama.cpp):&lt;/strong&gt; CPU inference on an ordinary Windows laptop
(~5–10 tok/s for 4B) — no GPU, no vendor lock-in, fully inspectable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic guardrails around a small model:&lt;/strong&gt; a 4B model sometimes
"helpfully" suggests shrimp to a shrimp-allergic user, or returns malformed
JSON. So generation runs under a JSON grammar, with retries, plus a
rule-based allergen filter (including common misspellings) that drops unsafe
dishes before they ever reach the screen. Small open models are honest about
their limits — you can see exactly where they end and your code begins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything was built Oct 2–4, 2026, in this repo. Nothing predates the window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Three reasons, all concrete:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Privacy.&lt;/strong&gt; Allergy and health data never leaves the laptop. There is no
server to breach, subpoena, or acqui-hire. For health-adjacent software,
"trust us" is not an architecture — &lt;em&gt;no data&lt;/em&gt; is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline reality.&lt;/strong&gt; His dorm wifi dies regularly. A closed API turns this
app into a loading spinner; a local model turns dead wifi into a non-event.
I verified with airplane mode on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost and control.&lt;/strong&gt; $0 to run, forever — this matters for a student. And
when a better open Vietnamese-capable model drops, it's a file swap, not a
migration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A closed model would have written prettier Vietnamese on day one. It would also&lt;br&gt;
have uploaded my friend's health profile to someone else's computer, billed per&lt;br&gt;
dinner plan, and stopped working the moment the wifi did. That's a bad trade,&lt;br&gt;
and it's exactly the trade open weights let you refuse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Use of Gemma&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>hf26challenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>devchallenge</category>
    </item>
    <item>
      <title>How a Fox and a Single Quote Broke the Critter Gallery — SQLi in Intigriti Challenge 0926</title>
      <dc:creator>duchu.nft</dc:creator>
      <pubDate>Tue, 29 Sep 2026 01:07:17 +0000</pubDate>
      <link>https://dev.to/duchuaz/how-a-fox-and-a-single-quote-broke-the-critter-gallery-sqli-in-intigriti-challenge-0926-4fhm</link>
      <guid>https://dev.to/duchuaz/how-a-fox-and-a-single-quote-broke-the-critter-gallery-sqli-in-intigriti-challenge-0926-4fhm</guid>
      <description>&lt;h1&gt;
  
  
  How a Fox and a Single Quote Broke the Critter Gallery — SQLi in Intigriti Challenge 0926
&lt;/h1&gt;

&lt;p&gt;My write-up for &lt;a href="https://go.intigriti.com/submit-solution" rel="noopener noreferrer"&gt;Intigriti Challenge 0926&lt;/a&gt;: a cute animal gallery hiding a textbook SQL injection behind a base64 &lt;code&gt;?pic=&lt;/code&gt; parameter. Solved 27/09/2026, submission &lt;code&gt;INTIGRITI-TAPV7AA2&lt;/code&gt; accepted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The challenge
&lt;/h2&gt;

&lt;p&gt;The Critter Gallery shows one animal per page. The animal is picked via a base64-encoded &lt;code&gt;?pic=&lt;/code&gt; query parameter that decodes to the critter's name, e.g. &lt;code&gt;?pic=Rk9Y&lt;/code&gt; → &lt;code&gt;FOX&lt;/code&gt;. Beneath each picture sits a short description pulled from a database. Somewhere on the server, a second table — &lt;code&gt;secret_vault&lt;/code&gt; — holds the flag.&lt;/p&gt;

&lt;p&gt;The official tip: &lt;em&gt;"the gallery speaks different languages"&lt;/em&gt; — the same input is processed differently by two layers of the stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  First look: two code paths, one input
&lt;/h2&gt;

&lt;p&gt;Requesting &lt;code&gt;FOX&lt;/code&gt; vs &lt;code&gt;Fox&lt;/code&gt; was the first tell. Both returned the fox description, but the ASCII art differed — one fell back to a default drawing. So the image lookup and the description lookup clearly don't share the same comparison logic: the art is matched one way (PHP, exact/case-sensitive), the description another (MySQL, case-insensitive collation with fullwidth folding). Two languages, exactly as the tip promised.&lt;/p&gt;

&lt;p&gt;That mismatch told me the description path talks directly to SQL — worth poking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the injection: the blank page
&lt;/h2&gt;

&lt;p&gt;Classic quote test, base64-wrapped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;'&lt;/code&gt; → &lt;strong&gt;blank white page&lt;/strong&gt; (zero-length response)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;\&lt;/code&gt; → blank page too&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"&lt;/code&gt; → normal page, unaffected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single quote killing the page while a double quote does nothing means the input lands inside a &lt;strong&gt;single-quoted SQL string&lt;/strong&gt;. The backslash breaking it too confirms the quote isn't being escaped — it's raw string concatenation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirming output: dumping all descriptions
&lt;/h2&gt;

&lt;p&gt;Next, a boolean break-out to prove the query result reaches the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?pic=JyBPUiAnMSc9JzE=        # base64(' OR '1'='1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The description box printed &lt;strong&gt;all 8 rows&lt;/strong&gt; instead of one. Injection confirmed, and the output lands in &lt;code&gt;div.desc&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Column count: UNION needs exactly one
&lt;/h2&gt;

&lt;p&gt;UNION-based extraction needs the right column count. Probing &lt;code&gt;ORDER BY&lt;/code&gt; / UNION with 1..n columns showed the query selects a &lt;strong&gt;single column&lt;/strong&gt; (&lt;code&gt;SELECT desc ...&lt;/code&gt;), and UNION output renders in the same description div.&lt;/p&gt;

&lt;p&gt;Fingerprinting via UNION:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?pic=JyBVTklPTiBTRUxFQ1QgQEB2ZXJzaW9uIC0tIC0=   # base64(' UNION SELECT @@version -- -)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;→ &lt;strong&gt;MySQL 8.0.46&lt;/strong&gt;, database &lt;code&gt;critter_gallery&lt;/code&gt;, tables &lt;code&gt;animals&lt;/code&gt; and &lt;code&gt;secret_vault&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vault
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;secret_vault&lt;/code&gt; has two columns: &lt;code&gt;id, note&lt;/code&gt;. One shot to dump it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;zzz&lt;/span&gt;&lt;span class="s1"&gt;' UNION SELECT GROUP_CONCAT(id,'&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="s1"&gt;',note SEPARATOR '&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="s1"&gt;') FROM secret_vault -- -
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="n"&gt;pic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;enp6JyBVTklPTiBTRUxFQ1QgR1JPVVBfQ09OQ0FUKGlkLCd&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;Jyxub3RlIFNFUEFSQVRPUiAnfCcpIEZST00gc2VjcmV0X3ZhdWx0IC0tIC0&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The description box returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1~INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(flag as rendered; submitted value &lt;code&gt;INTIGRITI{01a09f56-74a2-700b-a849-ffe6742327b2}&lt;/code&gt;, accepted ✅)&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral diffs are the real tip.&lt;/strong&gt; The &lt;code&gt;FOX&lt;/code&gt;/&lt;code&gt;Fox&lt;/code&gt; art mismatch revealed two comparison semantics before I sent a single quote. When one input gets two treatments, one of them is usually injectable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A blank page is an answer.&lt;/strong&gt; Zero-length responses on &lt;code&gt;'&lt;/code&gt; and &lt;code&gt;\&lt;/code&gt; (but not &lt;code&gt;"&lt;/code&gt;) fingerprint a single-quoted context with no escaping — no error message needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dump small, then go wide.&lt;/strong&gt; &lt;code&gt;' OR '1'='1&lt;/code&gt; (8 rows in the desc box) proved output control before I spent requests on schema enumeration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your collations.&lt;/strong&gt; PHP exact-match vs MySQL case-insensitive/fullwidth-folding comparison is a classic desync primitive — it shows up in auth bypasses too, not just galleries.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Thanks to the Intigriti team for a fun challenge. Full probe trail (14 rounds, from recon to flag) is documented in my notes.&lt;/p&gt;

</description>
      <category>security</category>
      <category>sql</category>
      <category>bugbounty</category>
    </item>
  </channel>
</rss>
